270 lines
10 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|