Files
fanuzu/app/lib/app.dart
T

270 lines
10 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 '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<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();
}
}
@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<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) 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',
// 루트에서 하단 안전 영역을 잘라 내지 않는다 — 잘라 내면 바 높이만큼 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,
),
),
),
),
);
}