359 lines
14 KiB
Dart
359 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
|
|
import 'core/config/feature_flags.dart' show isOlderVersion;
|
|
import 'core/data/repositories.dart';
|
|
import 'core/i18n/app_locale.dart';
|
|
import 'core/ui/adaptive_planet_effects.dart';
|
|
import 'core/push/push_messaging.dart';
|
|
import 'core/ui/app_dialog.dart';
|
|
import 'core/router/app_router.dart';
|
|
import 'features/home/home_screen.dart' show homeRefreshSignal;
|
|
import 'core/theme/app_theme.dart';
|
|
import 'features/auth/auth_repository.dart';
|
|
import 'features/campaign/campaign_detail_screen.dart';
|
|
import 'features/eclipse/eclipse_defend_screen.dart' show eclipseDefendRoute;
|
|
import 'features/eclipse/widgets/live_proof.dart' show liveTickerShowing;
|
|
import 'features/notification/notification_screen.dart';
|
|
import 'features/planet/planet_detail_screen.dart';
|
|
import 'features/quest/quest_detail_screen.dart';
|
|
import 'features/translation/translation_comments.dart';
|
|
|
|
/// MaterialApp + 라우터 + 테마 주입.
|
|
class FancakeApp extends StatefulWidget {
|
|
const FancakeApp({
|
|
super.key,
|
|
required this.auth,
|
|
this.repositories,
|
|
this.locale,
|
|
});
|
|
|
|
final AuthRepository auth;
|
|
|
|
/// 저장소 묶음. 라우터와 푸시가 같은 것을 봐야 하므로 여기서 한 번만 만든다.
|
|
/// 테스트에서 목 모드를 고정할 때만 넘긴다.
|
|
final Repositories? repositories;
|
|
|
|
/// 표시 언어. 테스트가 언어를 고정할 때만 넘긴다.
|
|
final LocaleController? locale;
|
|
|
|
@override
|
|
State<FancakeApp> createState() => _FancakeAppState();
|
|
}
|
|
|
|
class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
|
late final Repositories _repos = widget.repositories ?? Repositories();
|
|
late final GoRouter _router = buildRouter(widget.auth, repositories: _repos);
|
|
|
|
/// 표시 언어 — 정책 원장 B-76. 서버가 붙어 있으면 `i18n/{locale}` 원격 문자열을
|
|
/// 덮고(FR-I18N-01), 고른 언어를 `users/{uid}.locale` 에 남긴다(설계서 §2.1).
|
|
late final LocaleController _locale =
|
|
widget.locale ??
|
|
(LocaleController(
|
|
remoteSource: _repos.live
|
|
? (tag) => _repos.backend.db
|
|
.doc('i18n/$tag')
|
|
.snapshots()
|
|
.map((s) => s.data() ?? const <String, Object?>{})
|
|
: null,
|
|
onSaved: _repos.live ? _saveLocale : null,
|
|
)..restore());
|
|
|
|
/// 로그인 직후 — 이 기기에서 고른 언어가 없으면 프로필(`users/{uid}.locale`)을 따른다.
|
|
Future<void> _adoptProfileLocale() async {
|
|
final uid = _repos.backend.uid;
|
|
if (uid == null) return;
|
|
try {
|
|
final snap = await _repos.backend.db.doc('users/$uid').get();
|
|
await _locale.adoptProfile(snap.data()?['locale'] as String?);
|
|
} catch (e) {
|
|
debugPrint('[FancakeApp] profile locale: ${e.runtimeType}');
|
|
}
|
|
}
|
|
|
|
Future<void> _saveLocale(String tag) async {
|
|
final uid = _repos.backend.uid;
|
|
if (uid == null) return;
|
|
await _repos.backend.db.doc('users/$uid').update({
|
|
'locale': tag,
|
|
'updatedAt': FieldValue.serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
/// 포그라운드 알림을 앱 안에서 띄울 통로. 라우터 밖이라 `context` 를 쓸 수 없다.
|
|
final _messengerKey = GlobalKey<ScaffoldMessengerState>();
|
|
|
|
/// 푸시를 이미 켰는가. 로그인 알림은 여러 번 오므로 한 번만 켠다.
|
|
bool _pushStarted = false;
|
|
|
|
StreamSubscription<String>? _storeMessages;
|
|
|
|
/// HUD 「LIVE」 입력 — 포그라운드인 동안 `users/{uid}.lastSeenAt` 을 서버 시각으로 올린다
|
|
/// (해석 확인 요청 Q-GM-03 A안 · 서버가 최근 5분 운영값으로 센다). 간격 2분은 창보다 짧아
|
|
/// 앱을 켜 둔 사람이 집계에서 빠지지 않게 한다.
|
|
Timer? _presence;
|
|
|
|
void _startPresence() {
|
|
_presence?.cancel();
|
|
if (!_repos.live || !widget.auth.isSignedIn) return;
|
|
_touchPresence();
|
|
_presence = Timer.periodic(
|
|
const Duration(minutes: 2),
|
|
(_) => _touchPresence(),
|
|
);
|
|
}
|
|
|
|
Future<void> _touchPresence() async {
|
|
final uid = _repos.backend.uid;
|
|
if (uid == null) return;
|
|
try {
|
|
await _repos.backend.db.doc('users/$uid').update({
|
|
'lastSeenAt': FieldValue.serverTimestamp(),
|
|
});
|
|
} catch (e) {
|
|
debugPrint('[FancakeApp] presence: ${e.runtimeType}');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
_startPresence();
|
|
} else if (state == AppLifecycleState.paused) {
|
|
_presence?.cancel();
|
|
}
|
|
}
|
|
|
|
/// 업데이트 확인을 마쳤는가 — 앱 실행당 한 번만 안내한다.
|
|
bool _updateChecked = false;
|
|
bool _updateChecking = false;
|
|
|
|
/// 스플래시 업데이트 확인 — Figma `510:19053` 「앱 버전 정보 · 최신 버전 여부 확인 ·
|
|
/// 업데이트 안내 노출」. 최신 버전은 운영값 `config/features.latestAppVersion` 이고,
|
|
/// 값이 없거나 설치 버전을 못 읽으면 안내하지 않는다.
|
|
Future<void> _checkUpdate() async {
|
|
final latest = _repos.flags.latestVersion;
|
|
if (_updateChecked || _updateChecking || latest == null) return;
|
|
_updateChecking = true;
|
|
try {
|
|
final installed = (await PackageInfo.fromPlatform()).version;
|
|
_updateChecked = true;
|
|
if (!isOlderVersion(installed, latest)) return;
|
|
final context = _router.routerDelegate.navigatorKey.currentContext;
|
|
if (context == null || !context.mounted) {
|
|
_updateChecked = false; // 화면이 아직 없으면 다음 알림 때 다시 본다.
|
|
return;
|
|
}
|
|
await showAlertDialog(
|
|
context,
|
|
context.l10n.updateAvailableTitle,
|
|
body: context.l10n.updateAvailableBody(latest),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[FancakeApp] update check: ${e.runtimeType}');
|
|
} finally {
|
|
_updateChecking = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_repos.flags.addListener(_checkUpdate);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
widget.auth.addListener(_onAuthChanged);
|
|
_onAuthChanged();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_repos.flags.removeListener(_checkUpdate);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_presence?.cancel();
|
|
widget.auth.removeListener(_onAuthChanged);
|
|
_repos.push?.dispose();
|
|
if (widget.locale == null) _locale.dispose();
|
|
unawaited(_storeMessages?.cancel());
|
|
unawaited(_repos.store?.dispose());
|
|
super.dispose();
|
|
}
|
|
|
|
/// 로그인 이후에 푸시를 켠다 — 토큰을 `users/{uid}` 에 붙이므로 UID 가 필요하다.
|
|
///
|
|
/// **로그아웃하면 플래그를 내린다.** 내리지 않으면 같은 앱 실행 안에서 다시
|
|
/// 로그인했을 때 토큰 저장과 토픽 구독이 통째로 건너뛰어지고, 그 계정은 알림을
|
|
/// 하나도 받지 못한다. 화면은 정상으로 보여 원인이 드러나지 않는다.
|
|
void _onAuthChanged() {
|
|
if (!widget.auth.isSignedIn) {
|
|
_pushStarted = false;
|
|
_presence?.cancel();
|
|
return;
|
|
}
|
|
if (_presence == null || !_presence!.isActive) _startPresence();
|
|
// 결제 알림은 검증에 UID 가 필요해 로그인 뒤에 듣는다. 두 번 불러도 한 번만 붙는다.
|
|
final store = _repos.store;
|
|
if (store != null && _storeMessages == null) {
|
|
store.start();
|
|
_storeMessages = store.messages.listen(_showMessage);
|
|
}
|
|
if (_pushStarted) return;
|
|
if (_repos.live) _adoptProfileLocale();
|
|
final push = _repos.push;
|
|
if (push == null) return; // 목 모드. 플러그인을 부를 Firebase 가 없다.
|
|
_pushStarted = true;
|
|
_startPush(push);
|
|
}
|
|
|
|
Future<void> _startPush(PushMessaging push) async {
|
|
// 알림 권한은 여기서 묻지 않는다 — 온보딩 ⑥ 권한 안내가 쓰임을 설명한 뒤 묻는다
|
|
// (FR-CMN-08 · 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
|
|
// 사용자가 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
|
|
await push.syncToken();
|
|
|
|
// 팬덤 ID 는 PushMessaging 이 사용자 문서에서 직접 읽는다.
|
|
await push.syncTopics(await _repos.my.pushSettings());
|
|
await push.listen(onForeground: _showInApp, onOpened: _openFromPush);
|
|
}
|
|
|
|
void _showMessage(String message) => _messengerKey.currentState
|
|
?..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)),
|
|
);
|
|
|
|
/// 포그라운드 수신 — 시스템 알림이 뜨지 않으므로 앱이 직접 보여 준다.
|
|
/// 탭하면 알림을 눌렀을 때와 같은 곳으로 간다.
|
|
void _showInApp(PushNotice notice) {
|
|
// 타임어택 발동 · 퀘스트 완료 등 — HOME 카드가 바로 따라오게 다시 읽힌다.
|
|
homeRefreshSignal.value++;
|
|
if (!notice.hasText ||
|
|
!shouldShowInApp(
|
|
notice,
|
|
_inAppShowing,
|
|
ticker: _homeOnTop ? liveTickerShowing : null,
|
|
)) {
|
|
return;
|
|
}
|
|
final messenger = _messengerKey.currentState;
|
|
if (messenger == null) return;
|
|
messenger.hideCurrentSnackBar();
|
|
_inAppShowing = notice;
|
|
messenger
|
|
.showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
// action 이 있으면 기본으로 남아 있으므로(Flutter 3.29+) 시간이 지나면 닫히게 한다.
|
|
persist: false,
|
|
duration: const Duration(seconds: 4),
|
|
content: Text(
|
|
[notice.title, notice.body].where((s) => s.isNotEmpty).join('\n'),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
action: SnackBarAction(
|
|
label: (_messengerKey.currentContext ?? context).l10n.commonView,
|
|
onPressed: () => _openFromPush(notice),
|
|
),
|
|
),
|
|
)
|
|
.closed
|
|
.then((_) {
|
|
if (identical(_inAppShowing, notice)) _inAppShowing = null;
|
|
});
|
|
}
|
|
|
|
/// 지금 떠 있는 앱 안 알림. 닫히면 비운다.
|
|
PushNotice? _inAppShowing;
|
|
|
|
/// HOME 탭이 맨 위인가 — 루트에 덮인 화면이 없을 때만 라이브 띠가 보인다.
|
|
bool get _homeOnTop =>
|
|
_router.routerDelegate.currentConfiguration.uri.path == '/home' &&
|
|
!(_router.routerDelegate.navigatorKey.currentState?.canPop() ?? false);
|
|
|
|
/// 알림을 눌렀을 때 — 탭으로 간 뒤 실제 해당 화면을 연다(v1.5 8장 Deep Link · 9장 Eclipse).
|
|
void _openFromPush(PushNotice notice) {
|
|
_router.go(notice.route);
|
|
final route = _pushRoute(notice.target);
|
|
if (route == null) return;
|
|
// 탭 전환이 그려진 다음에 연다. 그 전에 열면 go 가 새로 짠 화면 스택이 덮는다.
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => _router.routerDelegate.navigatorKey.currentState?.push(route),
|
|
);
|
|
}
|
|
|
|
Route<void>? _pushRoute(String? target) => switch (target?.split('/')) {
|
|
['eclipse', final id] when id.isNotEmpty => eclipseDefendRoute(
|
|
repository: _repos.eclipse,
|
|
fandomId: id,
|
|
),
|
|
['planets', final id] when id.isNotEmpty => MaterialPageRoute(
|
|
builder: (_) => PlanetDetailScreen(
|
|
repository: _repos.planet(id),
|
|
watchPlanet: _repos.watchPlanet,
|
|
eclipse: _repos.eclipse,
|
|
),
|
|
),
|
|
_ => switch (target == null ? null : _notificationScreen(target)) {
|
|
final screen? => MaterialPageRoute(builder: (_) => screen),
|
|
null => null,
|
|
},
|
|
};
|
|
|
|
/// 알림함 항목 → 화면(명세 `518:88353`). INSIDE · 라운지 게시물 이동은 그 알림을 만드는
|
|
/// 3차 범위와 함께 붙는다 — 지금은 경로를 모르는 것으로 두어 이동하지 않는다.
|
|
Widget? _notificationScreen(String path) => switch (path.split('/')) {
|
|
['quests', final id] when id.isNotEmpty => QuestDetailScreen(
|
|
questId: id,
|
|
repository: _repos.quest,
|
|
),
|
|
['campaigns', final id] when id.isNotEmpty => CampaignDetailScreen(
|
|
campaignId: id,
|
|
repository: _repos.campaign,
|
|
),
|
|
_ => null,
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ListenableBuilder(
|
|
listenable: _locale,
|
|
builder: (context, _) => LocaleScope(
|
|
controller: _locale,
|
|
// 캠페인 상세 · 라운지 글 상세처럼 여러 경로로 열리는 화면이 번역 저장소를 찾는 곳.
|
|
child: TranslationScope(
|
|
repository: _repos.translation,
|
|
child: NotificationTargets(
|
|
screenFor: _notificationScreen,
|
|
child: MaterialApp.router(
|
|
title: 'FANUZU',
|
|
// 루트에서 하단 안전 영역을 잘라 내지 않는다 — 잘라 내면 바 높이만큼 cosmicVoid 띠가 생기고
|
|
// 바텀시트가 화면 끝까지 내려오지 못한다(10/6 실기기 · Figma 는 하단까지 화면 배경).
|
|
// 하단 여백을 직접 정하는 화면이 `withBottomInset`(core/ui/bottom_inset.dart)으로 바 높이를 더한다.
|
|
builder: (context, child) =>
|
|
AdaptivePlanetEffects(child: child ?? const SizedBox()),
|
|
debugShowCheckedModeBanner: false,
|
|
scaffoldMessengerKey: _messengerKey,
|
|
locale: _locale.locale,
|
|
supportedLocales: appLocales,
|
|
localizationsDelegates: [
|
|
RemoteL10nDelegate(_locale.remote),
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
theme: buildAppTheme(),
|
|
// 코스믹 다크가 유일한 서피스다(결정 D-19). 라이트 테마를 두지 않는다.
|
|
themeMode: ThemeMode.dark,
|
|
darkTheme: buildAppTheme(),
|
|
routerConfig: _router,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|