알림함 항목 생성 · 알림 이동 · 브리핑 항목 승인·상세 · 푸시 토픽을 가입 팬덤 전체로

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:10 +09:00
parent fbf9a2c3d7
commit cf608a1d99
27 changed files with 714 additions and 69 deletions
+39 -19
View File
@@ -12,6 +12,9 @@ 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/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 + 라우터 + 테마 주입.
@@ -202,6 +205,20 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
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,
@@ -210,25 +227,28 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
// 캠페인 상세 · 라운지 글 상세처럼 여러 경로로 열리는 화면이 번역 저장소를 찾는 곳.
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,
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,
),
),
),
),
+28 -20
View File
@@ -32,8 +32,8 @@ class PushMessaging {
StreamSubscription<RemoteMessage>? _opened;
StreamSubscription<String>? _tokenRefresh;
/// 소속 팬덤. 토픽 이름의 일부라 매번 사용자 문서를 읽지 않도록 기억한다.
String? _fandomId;
/// 가입한 팬덤 전부. 토픽 이름의 일부라 매번 읽지 않도록 기억한다.
Set<String>? _fandomIds;
/// 알림 권한 상태. 화면이 「거부됨」 안내와 재요청 버튼을 그릴 근거다.
AuthorizationStatus _status = AuthorizationStatus.notDetermined;
@@ -80,16 +80,25 @@ class PushMessaging {
}, SetOptions(merge: true));
}
/// 소속 팬덤 ID. 한 번 읽어 두고 다시 읽지 않는다.
Future<String> _resolveFandomId() async {
final cached = _fandomId;
/// 가입한 팬덤 ID 전부 — 대표 팬덤(`users.fandomId`) + 가입 목록(`users/{uid}/memberships`).
///
/// PRD-06 「관심 아티스트를 등록한 사용자」 · 별첨 1 ③-5 「FCM 푸시 수신」 — 대표 팬덤
/// 하나만 구독하면 다른 행성의 브리핑 · 이클립스 예고를 받지 못한다.
Future<Set<String>> _resolveFandomIds() async {
final cached = _fandomIds;
if (cached != null) return cached;
final uid = _backend.uid;
if (uid == null) return '';
if (uid == null) return const {};
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;
final memberships = await _backend.db
.collection('users/$uid/memberships')
.get();
final ids = {
(user.data() ?? const <String, dynamic>{}).strOr('fandomId'),
for (final d in memberships.docs) d.id,
}..remove('');
if (ids.isNotEmpty) _fandomIds = ids;
return ids;
}
/// 설정대로 토픽을 맞춘다.
@@ -102,14 +111,14 @@ class PushMessaging {
/// 팬덤 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);
for (final fandomId in await _resolveFandomIds()) {
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);
}
}
}
@@ -142,8 +151,7 @@ class PushMessaging {
/// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨
/// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다.
Future<void> clearOnSignOut() async {
final fandomId = await _resolveFandomId();
if (fandomId.isNotEmpty) {
for (final fandomId in await _resolveFandomIds()) {
for (final topic in PushTopic.values) {
await _transport.unsubscribeFromTopic(topic.topicFor(fandomId));
}
@@ -158,7 +166,7 @@ class PushMessaging {
}
// 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에
// 구독해 버린다.
_fandomId = null;
_fandomIds = null;
}
Future<void> dispose() async {
+5
View File
@@ -247,6 +247,7 @@ class BriefingItem {
required this.sourceUrl,
required this.articleUrl,
required this.aiSummary,
this.sourceKind,
});
/// 구획 안에서의 순서. 구획마다 1 부터 시작한다.
@@ -271,6 +272,10 @@ class BriefingItem {
/// AI 요약 본문.
final String aiSummary;
/// 출처 구분 — `official` · `fandom`(PRD-06 「공식/팬덤 출처 구분 표시」). 수신 규격에서
/// 선택 필드라 없으면 표시하지 않는다.
final String? sourceKind;
}
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
@@ -290,6 +290,10 @@ class FirestoreHomeRepository implements HomeRepository {
sourceUrl: sourceUrl,
articleUrl: articleUrl,
aiSummary: aiSummary,
sourceKind: switch (data.strOr('sourceKind')) {
final k when k == 'official' || k == 'fandom' => k,
_ => null,
},
);
}
@@ -12,11 +12,9 @@ import '../../../core/i18n/app_locale.dart';
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
/// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다.
///
/// **한 줄은 제목만 띄운다.** 출처명·게시시각·AI 요약을 어디에 배치할지는 발주사가
/// HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후 정한다.
/// 데이터는 [BriefingItem] 에 5 요소가 전부 들어와 있으므로 배치만 남는다.
///
/// 줄을 누르면 원문보기로 나간다(B-75 5 요소 중 「원문보기」).
/// **한 줄은 Figma 대로 번호 + 제목만 띄운다.** 정책 원장 B-75(확정) 「각 항목에 출처명 ·
/// 게시/발행시각 · 원본 URL · 원문보기 · AI 요약 표시」는 Figma 에 배치가 없어, 줄을 누르면
/// 여는 상세 시트([showBriefingDetail])에 모두 싣는다. 시트의 「원문 보기」가 원문으로 나간다.
class BriefingList extends StatelessWidget {
const BriefingList({super.key, required this.items, this.onOpen});
@@ -68,7 +66,14 @@ class BriefingList extends StatelessWidget {
),
for (final item in group.rows) ...[
const SizedBox(height: AppSpace.s5),
_Row(item: item, onOpen: () => _open(context, item)),
_Row(
item: item,
onOpen: () => showBriefingDetail(
context,
item,
onOriginal: () => _open(context, item),
),
),
],
],
],
@@ -145,3 +150,78 @@ class _Row extends StatelessWidget {
);
}
}
/// 브리핑 항목 상세 — B-75 5 요소(출처명 · 게시시각 · 원본 URL · 원문보기 · AI 요약 표시)와
/// 공식/팬덤 출처 구분을 한 시트에 싣는다.
Future<void> showBriefingDetail(
BuildContext context,
BriefingItem item, {
required Future<void> Function() onOriginal,
}) {
const muted = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.4,
color: AppColors.onCosmicMuted,
);
final l10n = context.l10n;
final kind = switch (item.sourceKind) {
'official' => l10n.briefingSourceOfficial,
'fandom' => l10n.briefingSourceFandom,
_ => null,
};
String two(int v) => v.toString().padLeft(2, '0');
final at = item.sourceAt.toLocal();
final stamp =
'${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.cosmicSurfaceContainer,
builder: (sheet) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpace.s5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
item.headline,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: AppSpace.s2),
Text([?kind, item.sourceName, stamp].join(' · '), style: muted),
const SizedBox(height: AppSpace.s4),
Text(l10n.briefingAiSummary, style: muted),
const SizedBox(height: AppSpace.s1),
Text(
item.aiSummary,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 15,
height: 1.5,
color: AppColors.onCosmicVariant,
),
),
const SizedBox(height: AppSpace.s3),
Text(item.sourceUrl, style: muted),
const SizedBox(height: AppSpace.s4),
FilledButton(
onPressed: () async {
Navigator.of(sheet).pop();
await onOriginal();
},
child: Text(l10n.briefingViewOriginal),
),
],
),
),
),
);
}
@@ -78,6 +78,7 @@ class AppNotification {
required this.at,
this.read = false,
this.source,
this.targetPath,
});
final String id;
@@ -91,6 +92,12 @@ class AppNotification {
final String? source;
String get sourceLabel => source ?? kind.label;
/// 눌렀을 때 열 대상 문서 경로(`quests/{id}` · `campaigns/{id}` 등). 서버가 싣는다.
/// 명세가 *이동 X* 로 정한 종류(이클립스 · 아이템)는 경로가 있어도 쓰지 않는다.
final String? targetPath;
String? get target => kind.navigable ? targetPath : null;
}
/// 날짜 머리글. 명세 — *오늘 및 어제를 제외한 과거는 yyyy.mm.dd 로 표시*.
@@ -223,6 +230,15 @@ class FirestoreNotificationRepository implements NotificationRepository {
'' => null,
final v => v,
},
// 캠페인 정산 알림(`campaign/settle.ts`)은 경로 대신 `campaignId` 를 싣는다.
targetPath: switch ((
doc.data().strOr('targetPath'),
doc.data().strOr('campaignId'),
)) {
(final p, _) when p.isNotEmpty => p,
(_, final c) when c.isNotEmpty => 'campaigns/$c',
_ => null,
},
),
];
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
@@ -9,6 +11,25 @@ import '../../core/i18n/app_locale.dart';
/// 이 화면을 임포트한 곳이 모델까지 함께 쓰므로 그대로 내보낸다.
export 'notification_data.dart';
/// 알림이 가리키는 문서 경로 → 열 화면. 앱 루트(`app.dart`)가 저장소를 쥐고 준다.
/// 화면을 만들지 못하는 경로(아직 이동 화면이 없는 종류)는 null — 누르면 읽음만 남는다.
class NotificationTargets extends InheritedWidget {
const NotificationTargets({
super.key,
required this.screenFor,
required super.child,
});
final Widget? Function(String targetPath) screenFor;
static NotificationTargets? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<NotificationTargets>();
@override
bool updateShouldNotify(NotificationTargets old) =>
old.screenFor != screenFor;
}
/// 알림 — 화면 대조 원장 F0 · Figma `229:11265`.
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key, required this.repository});
@@ -90,6 +111,13 @@ class _NotificationScreenState extends State<NotificationScreen> {
);
}
/// 명세 `518:88353` 종류별 이동 규칙 — 좋아요 · 댓글 → 게시물, 캠페인 달성 → 캠페인,
/// 퀘스트 완료 → 퀘스트 상세. 이클립스 · 아이템 사용은 이동하지 않는다.
Widget? _screenFor(AppNotification item) => switch (item.target) {
final path? => NotificationTargets.maybeOf(context)?.screenFor(path),
null => null,
};
Widget _tile(AppNotification item) {
final read = _read.contains(item.id);
final (fill, icon) = _badge(item.kind);
@@ -134,8 +162,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
],
),
),
// 이동할 곳이 없는 종류에는 화살표를 그리지 않는다.
if (item.kind.navigable) ...[
// 이동할 곳이 없는 알림에는 화살표를 그리지 않는다.
if (_screenFor(item) != null) ...[
const SizedBox(width: 13),
const ImageIcon(
AppIcons.chevronRight,
@@ -152,23 +180,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
// 처음 읽은 것만 서버에 알린다. 이미 읽은 알림을 다시 눌러도 쓰기가 없다.
final first = _read.add(item.id);
setState(() {});
if (item.kind.navigable && mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.notifGoTo('${item.kind.target}')),
behavior: SnackBarBehavior.floating,
),
);
}
if (!first) return;
try {
await widget.repository.markRead(item.id);
} on Exception {
// 읽음 표시가 실패해도 화면을 막지 않는다. 다음 진입에서 서버 값으로
if (first) {
// 읽음 표시가 실패해도 이동을 막지 않는다. 다음 진입에서 서버 값으로
// 되돌아오므로 잃는 것이 없고, 여기서 막으면 알림 자체를 못 읽는다.
unawaited(widget.repository.markRead(item.id).catchError((_) {}));
}
final screen = _screenFor(item);
if (screen == null || !mounted) return;
await Navigator.of(context)
.push<void>(MaterialPageRoute(builder: (_) => screen));
}
}
+4
View File
@@ -261,6 +261,10 @@
"planetCreateConfirm": "Create a planet\nwith these options?",
"commonCreate": "Create",
"briefingOpenFailed": "Couldn't open the original.",
"briefingAiSummary": "AI summary",
"briefingViewOriginal": "View original",
"briefingSourceOfficial": "Official source",
"briefingSourceFandom": "Fandom source",
"campaignAchieved": "{percent}% reached",
"campaignView": "View campaign",
"growthFinalReached": "You've reached the final stage",
+4
View File
@@ -261,6 +261,10 @@
"planetCreateConfirm": "¿Crear el planeta\ncon estas opciones?",
"commonCreate": "Crear",
"briefingOpenFailed": "No se puede abrir el original.",
"briefingAiSummary": "Resumen de IA",
"briefingViewOriginal": "Ver original",
"briefingSourceOfficial": "Fuente oficial",
"briefingSourceFandom": "Fuente del fandom",
"campaignAchieved": "{percent}% alcanzado",
"campaignView": "Ver campaña",
"growthFinalReached": "Has llegado a la etapa final",
+4
View File
@@ -261,6 +261,10 @@
"planetCreateConfirm": "選択したオプションで\n惑星を作成しますか?",
"commonCreate": "作成",
"briefingOpenFailed": "原文を開けません。",
"briefingAiSummary": "AI要約",
"briefingViewOriginal": "原文を見る",
"briefingSourceOfficial": "公式ソース",
"briefingSourceFandom": "ファンダムソース",
"campaignAchieved": "{percent}% 達成",
"campaignView": "キャンペーンを見る",
"growthFinalReached": "最終ステージに到達しました",
+4
View File
@@ -612,6 +612,10 @@
"planetCreateConfirm": "선택한 옵션으로\n행성을 생성할까요?",
"commonCreate": "생성",
"briefingOpenFailed": "원문을 열 수 없습니다.",
"briefingAiSummary": "AI 요약",
"briefingViewOriginal": "원문 보기",
"briefingSourceOfficial": "공식 출처",
"briefingSourceFandom": "팬덤 출처",
"campaignAchieved": "{percent}% 달성",
"@campaignAchieved": {
"placeholders": {
+24
View File
@@ -1670,6 +1670,30 @@ abstract class AppLocalizations {
/// **'원문을 열 수 없습니다.'**
String get briefingOpenFailed;
/// No description provided for @briefingAiSummary.
///
/// In ko, this message translates to:
/// **'AI 요약'**
String get briefingAiSummary;
/// No description provided for @briefingViewOriginal.
///
/// In ko, this message translates to:
/// **'원문 보기'**
String get briefingViewOriginal;
/// No description provided for @briefingSourceOfficial.
///
/// In ko, this message translates to:
/// **'공식 출처'**
String get briefingSourceOfficial;
/// No description provided for @briefingSourceFandom.
///
/// In ko, this message translates to:
/// **'팬덤 출처'**
String get briefingSourceFandom;
/// No description provided for @campaignAchieved.
///
/// In ko, this message translates to:
+12
View File
@@ -944,6 +944,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get briefingOpenFailed => 'Couldn\'t open the original.';
@override
String get briefingAiSummary => 'AI summary';
@override
String get briefingViewOriginal => 'View original';
@override
String get briefingSourceOfficial => 'Official source';
@override
String get briefingSourceFandom => 'Fandom source';
@override
String campaignAchieved(String percent) {
return '$percent% reached';
+12
View File
@@ -954,6 +954,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get briefingOpenFailed => 'No se puede abrir el original.';
@override
String get briefingAiSummary => 'Resumen de IA';
@override
String get briefingViewOriginal => 'Ver original';
@override
String get briefingSourceOfficial => 'Fuente oficial';
@override
String get briefingSourceFandom => 'Fuente del fandom';
@override
String campaignAchieved(String percent) {
return '$percent% alcanzado';
+12
View File
@@ -909,6 +909,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get briefingOpenFailed => '原文を開けません。';
@override
String get briefingAiSummary => 'AI要約';
@override
String get briefingViewOriginal => '原文を見る';
@override
String get briefingSourceOfficial => '公式ソース';
@override
String get briefingSourceFandom => 'ファンダムソース';
@override
String campaignAchieved(String percent) {
return '$percent% 達成';
+12
View File
@@ -909,6 +909,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get briefingOpenFailed => '원문을 열 수 없습니다.';
@override
String get briefingAiSummary => 'AI 요약';
@override
String get briefingViewOriginal => '원문 보기';
@override
String get briefingSourceOfficial => '공식 출처';
@override
String get briefingSourceFandom => '팬덤 출처';
@override
String campaignAchieved(String percent) {
return '$percent% 달성';
+12
View File
@@ -909,6 +909,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get briefingOpenFailed => '无法打开原文。';
@override
String get briefingAiSummary => 'AI 摘要';
@override
String get briefingViewOriginal => '查看原文';
@override
String get briefingSourceOfficial => '官方来源';
@override
String get briefingSourceFandom => '粉丝圈来源';
@override
String campaignAchieved(String percent) {
return '已达成 $percent%';
+4
View File
@@ -261,6 +261,10 @@
"planetCreateConfirm": "要用所选选项\n创建星球吗?",
"commonCreate": "创建",
"briefingOpenFailed": "无法打开原文。",
"briefingAiSummary": "AI 摘要",
"briefingViewOriginal": "查看原文",
"briefingSourceOfficial": "官方来源",
"briefingSourceFandom": "粉丝圈来源",
"campaignAchieved": "已达成 {percent}%",
"campaignView": "查看活动",
"growthFinalReached": "已到达最终阶段",
@@ -181,6 +181,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get blockedEmpty => _remote['blockedEmpty'] ?? _base.blockedEmpty;
@override
String get briefingAiSummary => _remote['briefingAiSummary'] ?? _base.briefingAiSummary;
@override
String get briefingOpenFailed => _remote['briefingOpenFailed'] ?? _base.briefingOpenFailed;
@@ -190,6 +193,15 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get briefingShareTextCopied => _remote['briefingShareTextCopied'] ?? _base.briefingShareTextCopied;
@override
String get briefingSourceFandom => _remote['briefingSourceFandom'] ?? _base.briefingSourceFandom;
@override
String get briefingSourceOfficial => _remote['briefingSourceOfficial'] ?? _base.briefingSourceOfficial;
@override
String get briefingViewOriginal => _remote['briefingViewOriginal'] ?? _base.briefingViewOriginal;
@override
String get calendarNextMonth => _remote['calendarNextMonth'] ?? _base.calendarNextMonth;