앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent cbfb72efe0
commit c4e9a9430e
165 changed files with 29991 additions and 54 deletions
+241
View File
@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_messaging/firebase_messaging.dart' show FirebaseMessaging;
import 'package:flutter/foundation.dart';
import '../data/backend.dart';
import '../../features/my/my_data.dart';
import 'push_transport.dart';
/// FCM 수신 — 정책 원장 B-77 「Push 알림」 5 개 구분의 앱 쪽 절반.
///
/// **구분마다 토픽을 나눈 이유가 여기 있다.** 서버는 토픽으로만 보내므로 수신자를
/// 고를 수 없다. 사용자가 「AI Briefing 만 끄기」를 고르면 앱이 그 토픽을 해지하고,
/// 그래서 **OFF 구분의 수신이 0 건**이 된다(B-77 검증 기준). 앱이 받아서 버리는
/// 방식이면 기기에 알림이 먼저 떠 버려 정책을 지킬 수 없다.
///
/// Marketing 은 기본값이 OFF 라 명시 동의 전에는 구독하지 않는다.
///
/// **iOS 는 코드 경로만이다.** APNs 키가 Apple Developer 계정 확정 대기(별첨 A-1)라
/// 토큰이 발급되지 않는다. 키가 들어오는 날 이 파일을 고치지 않아도 되도록
/// 지금 경로를 맞춰 둔다.
class PushMessaging {
PushMessaging({required Backend backend, PushTransport? transport})
: _backend = backend,
_transport = transport ?? FirebaseMessagingTransport();
final Backend _backend;
final PushTransport _transport;
StreamSubscription<RemoteMessage>? _foreground;
StreamSubscription<RemoteMessage>? _opened;
StreamSubscription<String>? _tokenRefresh;
/// 소속 팬덤. 토픽 이름의 일부라 매번 사용자 문서를 읽지 않도록 기억한다.
String? _fandomId;
/// 알림 권한 상태. 화면이 「거부됨」 안내와 재요청 버튼을 그릴 근거다.
AuthorizationStatus _status = AuthorizationStatus.notDetermined;
AuthorizationStatus get status => _status;
bool get isAuthorized => _status.isAllowed;
/// 알림 권한 요청.
///
/// Android 13+ 의 `POST_NOTIFICATIONS` 런타임 권한도 이 호출이 띄운다 —
/// 플러그인이 매니페스트에 권한을 선언하고 `requestPermission` 이 시스템
/// 다이얼로그로 이어진다. 별도 권한 플러그인을 넣지 않은 이유가 이것이다.
Future<AuthorizationStatus> requestPermission() async {
_status = await _transport.requestPermission();
return _status;
}
/// 현재 권한 상태만 읽는다. 다이얼로그를 띄우지 않으므로 화면 진입 시 쓴다.
Future<AuthorizationStatus> refreshStatus() async {
_status = await _transport.currentStatus();
return _status;
}
/// 단말 토큰을 `users/{uid}.fcmTokens` 에 더한다.
///
/// 배열로 두는 이유는 한 계정이 여러 단말을 쓰기 때문이다. 단일 필드면 새 단말
/// 로그인이 이전 단말의 토큰을 지우고, 그 단말은 조용히 알림을 못 받는다.
/// 토큰 재발급도 같은 경로로 흘러 들어온다([listen] 의 `onTokenRefresh`).
///
/// iOS 는 APNs 키 전까지 토큰이 null 이다 — 예외가 아니라 정상이다.
Future<String?> syncToken() async {
final token = await _transport.getToken();
if (token == null || token.isEmpty) return null;
await _saveToken(token);
return token;
}
Future<void> _saveToken(String token) async {
final uid = _backend.uid;
if (uid == null) return;
await _backend.db.doc('users/$uid').set({
'fcmTokens': FieldValue.arrayUnion([token]),
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
/// 소속 팬덤 ID. 한 번 읽어 두고 다시 읽지 않는다.
Future<String> _resolveFandomId() async {
final cached = _fandomId;
if (cached != null) return cached;
final uid = _backend.uid;
if (uid == null) return '';
final user = await _backend.db.doc('users/$uid').get();
final id = (user.data() ?? const <String, dynamic>{}).strOr('fandomId');
if (id.isNotEmpty) _fandomId = id;
return id;
}
/// 설정대로 토픽을 맞춘다.
///
/// **구독 상태를 앱이 기억하지 않는다.** 켜진 구분은 구독을, 꺼진 구분은 해지를
/// 조건 없이 부른다. FCM 의 두 호출은 멱등이라 이미 그 상태여도 문제가 없고,
/// 반대로 앱이 기억한 상태를 믿으면 재설치·토큰 재발급·다른 기기 로그인처럼
/// 앱이 모르는 사이에 실제 구독이 달라진 경우를 되돌릴 방법이 없다.
///
/// 팬덤 ID 는 사용자 문서에서 스스로 읽는다. 호출자가 넘기게 하면 설정 화면이
/// 그 값을 알아야 하고, 화면이 알 이유가 없다.
Future<void> syncTopics(Map<PushTopic, bool> prefs) async {
final fandomId = await _resolveFandomId();
if (fandomId.isEmpty) return;
final plan = topicPlan(fandomId, prefs);
for (final topic in plan.subscribe) {
await _transport.subscribeToTopic(topic);
}
for (final topic in plan.unsubscribe) {
await _transport.unsubscribeFromTopic(topic);
}
}
/// 수신 구독을 연다.
///
/// 포그라운드에서는 시스템 알림이 뜨지 않으므로(Android 기본 동작) 앱이 직접
/// 표시해야 한다. 탭은 두 경로로 들어온다 — 앱이 살아 있을 때의
/// `onMessageOpenedApp` 과 종료 상태에서 알림으로 켠 `getInitialMessage`.
/// 후자를 빼면 「알림을 눌러 앱이 열렸는데 홈에 그냥 서 있다」가 된다.
Future<void> listen({
required void Function(PushNotice notice) onForeground,
required void Function(PushNotice notice) onOpened,
}) async {
_foreground = FirebaseMessaging.onMessage
.listen((m) => onForeground(PushNotice.of(m)));
_opened = FirebaseMessaging.onMessageOpenedApp
.listen((m) => onOpened(PushNotice.of(m)));
_tokenRefresh = _transport.onTokenRefresh.listen(_saveToken);
final initial = await _transport.getInitialMessage();
if (initial != null) onOpened(PushNotice.of(initial));
}
/// 로그아웃 정리 — 팬덤 토픽을 전부 해지하고 이 기기 토큰을 지운다.
///
/// **로그아웃보다 먼저 불러야 한다.** 보안 규칙이 `users/{uid}` 쓰기를
/// `isSelf(uid)` 로 막으므로 인증이 끊긴 뒤에는 토큰을 지울 수 없다. 그대로 두면
/// 다음 사용자가 그 기기를 쓸 때 이전 계정의 알림이 계속 내려간다.
///
/// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨
/// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다.
Future<void> clearOnSignOut() async {
final fandomId = await _resolveFandomId();
if (fandomId.isNotEmpty) {
for (final topic in PushTopic.values) {
await _transport.unsubscribeFromTopic(topic.topicFor(fandomId));
}
}
final uid = _backend.uid;
final token = await _transport.getToken();
if (uid != null && token != null && token.isNotEmpty) {
await _backend.db.doc('users/$uid').set({
'fcmTokens': FieldValue.arrayRemove([token]),
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
// 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에
// 구독해 버린다.
_fandomId = null;
}
Future<void> dispose() async {
await _foreground?.cancel();
await _opened?.cancel();
await _tokenRefresh?.cancel();
}
}
/// 알림을 실제로 받을 수 있는 상태인가.
///
/// `provisional` 을 포함하는 이유는 iOS 임시 승인이다. 사용자가 아직 명시적으로
/// 허용하지 않았어도 조용한 알림은 전달되므로, 「꺼져 있습니다」 안내를 띄우면
/// 사실과 다르다.
extension PushAuthorization on AuthorizationStatus {
bool get isAllowed =>
this == AuthorizationStatus.authorized ||
this == AuthorizationStatus.provisional;
}
/// 토픽 계획 — 무엇을 구독하고 무엇을 해지할지.
@immutable
class TopicPlan {
const TopicPlan({required this.subscribe, required this.unsubscribe});
final Set<String> subscribe;
final Set<String> unsubscribe;
}
/// 설정에서 계획을 낸다. 모든 구분이 둘 중 한쪽에 들어간다.
///
/// 플러그인을 부르지 않는 순수 함수로 떼어 둔 이유는 검증이다. 「Marketing 을 끈
/// 상태로는 그 토픽을 구독하지 않는다」가 정책 검증 기준이고, 그 판정을 실제 FCM
/// 없이 확인할 수 있어야 한다.
TopicPlan topicPlan(String fandomId, Map<PushTopic, bool> prefs) {
final subscribe = <String>{};
final unsubscribe = <String>{};
for (final topic in PushTopic.values) {
// 서비스 필수는 끌 수 없으므로 설정값을 보지 않는다.
final on = topic.locked || (prefs[topic] ?? topic.defaultOn);
(on ? subscribe : unsubscribe).add(topic.topicFor(fandomId));
}
return TopicPlan(subscribe: subscribe, unsubscribe: unsubscribe);
}
/// 받은 알림 한 건. 화면이 쓰는 형태로만 남긴다.
@immutable
class PushNotice {
const PushNotice({
required this.title,
required this.body,
required this.kind,
this.fandomId,
});
factory PushNotice.of(RemoteMessage message) => PushNotice(
title: message.notification?.title ?? '',
body: message.notification?.body ?? '',
kind: message.data['kind']?.toString() ?? '',
fandomId: message.data['fandomId']?.toString(),
);
final String title;
final String body;
/// 서버가 실은 분기 키 — `briefing` · `eclipse`.
final String kind;
final String? fandomId;
/// 탭했을 때 열 경로.
///
/// 브리핑 상세 화면은 아직 라우트가 없어 홈의 브리핑 블록으로 보낸다. 이클립스는
/// 갤럭시에서 행성으로 들어가는 동선이 이미 있다.
String get route => switch (kind) {
'eclipse' => '/galaxy',
_ => '/home',
};
/// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다.
bool get hasText => title.isNotEmpty || body.isNotEmpty;
}