import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import '../../core/data/backend.dart'; import '../../core/i18n/app_locale.dart'; /// 알림함이 그리는 값과 그 출처 — 화면 대조 원장 F0 · Figma `229:11265`. /// /// 명세 `518:88347` — *뒤로가기 클릭 시, 이전 화면 이동 및 **읽음 상태값 반환*** /// 명세 `518:88353` — *날짜 기준 그룹화 및 최신순 정렬 · 오늘 및 어제를 제외한* /// *과거는 yyyy.mm.dd 로 표시* · 종류별 이동 규칙 /// 알림 종류. **눌렀을 때 어디로 가는지가 종류마다 다르다**(명세). enum NotificationKind { eclipse(Icons.dark_mode_outlined), itemUsed(Icons.auto_awesome), like(Icons.favorite_border), comment(Icons.mode_comment_outlined), campaign(Icons.campaign_outlined), quest(Icons.checklist_rtl), /// 행성 진화 참여 알림(9/21 회신 「당신도 이 진화에 함께했습니다」). Figma `518:88353` 6종 밖이라 /// 이동하지 않는다 — 예전에는 아이템 사용으로 떨어져 노란 아이템 배지 · 「아이템 사용」으로 보였다. evolution(Icons.public); const NotificationKind(this.icon); final IconData icon; String get label => switch (this) { eclipse => appL10n.notifKindEclipse, itemUsed => appL10n.notifKindItemUsed, like => appL10n.notifKindLike, comment => appL10n.notifKindComment, campaign => appL10n.notifKindCampaign, quest => appL10n.notifKindQuest, evolution => appL10n.notifKindEvolution, }; /// 눌러서 갈 곳. `null` 이면 이동하지 않는다 — 명세가 이클립스와 아이템 /// 사용을 *클릭 이동 X* 로 정했다. String? get target => switch (this) { eclipse || itemUsed || evolution => null, like || comment => appL10n.notifTargetPost, campaign => appL10n.notifTargetCampaign, quest => appL10n.notifTargetQuest, }; bool get navigable => target != null; /// 서버 문서의 `eventType` → 종류. /// /// **알림 문서는 축이 둘이다.** /// - `type` 은 수신 설정 축이고 값은 정책 원장 **B-77** 의 5구분이다 /// (`essential` · `fandomEvent` · `briefing` · `community` · `marketing`) — /// 서버 `firebase/functions/src/notify/push.ts` 의 `PUSH_TOPICS` 와 같은 표기다. /// - `eventType` 은 알림함 표기·이동 축이고 값은 Figma `518:88353` 의 6종이다. /// /// 둘을 한 필드로 합칠 수 없다. B-77 의 「팬덤 이벤트」 하나가 Eclipse·Campaign· /// Quest 를 모두 담는데, 명세는 이클립스를 *이동 X*, 캠페인을 캠페인으로, /// 퀘스트를 퀘스트 상세로 보내라고 갈라 두었다. 카테고리만으로는 이동할 곳을 /// 정할 수 없다. /// /// `eventType` 이 없으면 `type` 을 넣어 본다. B-77 의 5값은 여기에 걸리지 않아 /// 이동하지 않는 [itemUsed] 로 떨어지는데, 그것이 의도한 동작이다. 종류를 /// 잘못 짚어 **없는 화면으로 보내는 것보다 안 보내는 쪽이 안전하다.** static NotificationKind parse(String raw) => switch (raw) { 'eclipse' => NotificationKind.eclipse, 'item_used' => NotificationKind.itemUsed, 'like' => NotificationKind.like, 'comment' => NotificationKind.comment, 'campaign' => NotificationKind.campaign, 'quest' => NotificationKind.quest, 'evolution' => NotificationKind.evolution, _ => NotificationKind.itemUsed, }; } class AppNotification { const AppNotification({ required this.id, required this.kind, required this.title, required this.at, this.read = false, this.source, this.targetPath, }); final String id; final NotificationKind kind; final String title; final DateTime at; final bool read; /// 둘째 줄 앞머리 — 시안은 알림이 난 곳을 쓴다(「INSIDE」 · 행성명 「BTS」 · 「퀘스트」). /// 없으면 종류 이름([NotificationKind.label])으로 물러난다. 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 로 표시*. String notificationDayLabel(DateTime at, {DateTime? now}) { final today = _dateOnly(now ?? DateTime.now()); final day = _dateOnly(at); final gap = today.difference(day).inDays; if (gap == 0) return appL10n.dayToday; if (gap == 1) return appL10n.dayYesterday; final y = day.year.toString().padLeft(4, '0'); final m = day.month.toString().padLeft(2, '0'); final d = day.day.toString().padLeft(2, '0'); return '$y.$m.$d'; } /// 생성일시. 명세 — *yyyy.mm.dd 00:00*. String notificationTimestamp(DateTime at) { final y = at.year.toString().padLeft(4, '0'); final m = at.month.toString().padLeft(2, '0'); final d = at.day.toString().padLeft(2, '0'); final hh = at.hour.toString().padLeft(2, '0'); final mm = at.minute.toString().padLeft(2, '0'); return '$y.$m.$d $hh:$mm'; } DateTime _dateOnly(DateTime at) => DateTime(at.year, at.month, at.day); /// 날짜별로 묶는다. 최신 날짜가 앞이고, 그룹 안에서도 최신이 앞이다. List<({String label, List items})> groupNotifications( List items, { DateTime? now, }) { final sorted = [...items]..sort((a, b) => b.at.compareTo(a.at)); final groups = >{}; for (final item in sorted) { groups .putIfAbsent(notificationDayLabel(item.at, now: now), () => []) .add(item); } return [ for (final entry in groups.entries) (label: entry.key, items: entry.value), ]; } abstract interface class NotificationRepository { Future> load({int limit = 50}); /// 읽음 처리. 명세가 뒤로가기에서 읽음 상태를 돌려주라 했으므로 화면을 떠날 /// 때가 아니라 **누른 그 순간** 서버에 남긴다. 떠날 때 한꺼번에 보내면 앱이 /// 죽거나 통신이 끊긴 경우에 읽음이 사라진다. Future markRead(String id); } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** class SampleNotificationRepository implements NotificationRepository { /// 목록을 뒤늦게 만드는 이유는 `const` 다. HOME·GALAXY 가 기존 표본 저장소와 /// 같은 모양으로 기본값에 쓸 수 있어야 한다. const SampleNotificationRepository({List? items}) : _items = items; final List? _items; static List _defaults() => [ AppNotification( id: 'n1', kind: NotificationKind.quest, title: '만보기 퀘스트를 완료했습니다', at: DateTime.now().subtract(const Duration(hours: 2)), ), AppNotification( id: 'n2', kind: NotificationKind.like, title: '아미보이님이 회원님의 글을 좋아합니다', at: DateTime.now().subtract(const Duration(hours: 5)), ), AppNotification( id: 'n3', kind: NotificationKind.eclipse, title: 'BTS 행성에 이클립스가 발생했습니다', at: DateTime.now().subtract(const Duration(days: 1, hours: 3)), read: true, ), AppNotification( id: 'n4', kind: NotificationKind.campaign, title: '강남역 LED 캠페인이 목표를 달성했습니다', at: DateTime.now().subtract(const Duration(days: 9)), read: true, ), ]; @override Future> load({int limit = 50}) async => (_items ?? _defaults()).take(limit).toList(); @override Future markRead(String id) async {} } /// 실 서버 구현 — `users/{uid}/notifications`. /// /// 규칙이 본인 읽기와 `read`·`readAt` 두 필드 수정만 허용한다. 생성과 삭제는 /// Functions(FCM) 경유라 앱에서 부를 경로 자체가 없다. class FirestoreNotificationRepository implements NotificationRepository { const FirestoreNotificationRepository(this._backend); final Backend _backend; @override Future> load({int limit = 50}) async { final uid = _backend.requireUid; final snapshot = await _backend.db .collection('users/$uid/notifications') .orderBy('createdAt', descending: true) .limit(limit) .get(); return [ for (final doc in snapshot.docs) AppNotification( id: doc.id, // `eventType`(이동 축) 우선, 없으면 `type`(수신 설정 축)으로 물러난다. kind: NotificationKind.parse( doc.data().strOr('eventType', doc.data().strOr('type')), ), title: doc.data().strOr('title', appL10n.homeNotificationsTooltip), at: doc.data().dateOr('createdAt', _epoch), read: doc.data().boolOr('read'), // 서버가 아직 쓰지 않는 필드다. 비면 종류 이름으로 물러난다. source: switch (doc.data().strOr('source')) { '' => 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, }, ), ]; } @override Future markRead(String id) async { final uid = _backend.requireUid; await _backend.db.doc('users/$uid/notifications/$id').update({ 'read': true, 'readAt': FieldValue.serverTimestamp(), }); } } /// 서버가 시각을 아직 채우지 않은 문서의 기본값. final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0);