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 '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/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/notification/notification_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 createState() => _FancakeAppState(); } class _FancakeAppState extends State 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 {}) : null, onSaved: _repos.live ? _saveLocale : null, )..restore()); /// 로그인 직후 — 이 기기에서 고른 언어가 없으면 프로필(`users/{uid}.locale`)을 따른다. Future _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 _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(); /// 푸시를 이미 켰는가. 로그인 알림은 여러 번 오므로 한 번만 켠다. bool _pushStarted = false; StreamSubscription? _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 _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(); } } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); widget.auth.addListener(_onAuthChanged); _onAuthChanged(); } @override void dispose() { 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 _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) return; _messengerKey.currentState ?..hideCurrentSnackBar() ..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), ), ), ); } void _openFromPush(PushNotice notice) => _router.go(notice.route); /// 알림함 항목 → 화면(명세 `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', 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, ), ), ), ), ); }