앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
+216
-11
@@ -1,31 +1,236 @@
|
||||
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 'core/theme/app_theme.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
import 'features/translation/translation_comments.dart';
|
||||
|
||||
/// MaterialApp + 라우터 + 테마 주입.
|
||||
class FancakeApp extends StatefulWidget {
|
||||
const FancakeApp({super.key, required this.auth});
|
||||
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> {
|
||||
late final GoRouter _router = buildRouter(widget.auth);
|
||||
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
|
||||
Widget build(BuildContext context) => MaterialApp.router(
|
||||
title: 'FANUZU',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
// 코스믹 다크가 유일한 서피스다(결정 D-19). 라이트 테마를 두지 않는다.
|
||||
themeMode: ThemeMode.dark,
|
||||
darkTheme: buildAppTheme(),
|
||||
routerConfig: _router,
|
||||
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 {
|
||||
// 권한을 먼저 묻는다. 거부해도 나머지는 계속한다 — 토큰과 구독은 사용자가
|
||||
// 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
|
||||
await push.requestPermission();
|
||||
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) {
|
||||
if (!notice.hasText) return;
|
||||
_messengerKey.currentState
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(notice.title.isEmpty ? notice.body : notice.title),
|
||||
action: SnackBarAction(
|
||||
label: (_messengerKey.currentContext ?? context).l10n.commonView,
|
||||
onPressed: () => _openFromPush(notice),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openFromPush(PushNotice notice) => _router.go(notice.route);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: _locale,
|
||||
builder: (context, _) => LocaleScope(
|
||||
controller: _locale,
|
||||
// 캠페인 상세 · 라운지 글 상세처럼 여러 경로로 열리는 화면이 번역 저장소를 찾는 곳.
|
||||
child: TranslationScope(
|
||||
repository: _repos.translation,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user