INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/// 눌러서 갈 곳. `null` 이면 이동하지 않는다 — 명세가 이클립스와 아이템
|
||||
/// 사용을 *클릭 이동 X* 로 정했다.
|
||||
String? get target => switch (this) {
|
||||
eclipse || itemUsed => 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,
|
||||
_ => NotificationKind.itemUsed,
|
||||
};
|
||||
}
|
||||
|
||||
class AppNotification {
|
||||
const AppNotification({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.at,
|
||||
this.read = false,
|
||||
this.source,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// 날짜 머리글. 명세 — *오늘 및 어제를 제외한 과거는 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<AppNotification> items})> groupNotifications(
|
||||
List<AppNotification> items, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final sorted = [...items]..sort((a, b) => b.at.compareTo(a.at));
|
||||
final groups = <String, List<AppNotification>>{};
|
||||
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<List<AppNotification>> load({int limit = 50});
|
||||
|
||||
/// 읽음 처리. 명세가 뒤로가기에서 읽음 상태를 돌려주라 했으므로 화면을 떠날
|
||||
/// 때가 아니라 **누른 그 순간** 서버에 남긴다. 떠날 때 한꺼번에 보내면 앱이
|
||||
/// 죽거나 통신이 끊긴 경우에 읽음이 사라진다.
|
||||
Future<void> markRead(String id);
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
class SampleNotificationRepository implements NotificationRepository {
|
||||
/// 목록을 뒤늦게 만드는 이유는 `const` 다. HOME·GALAXY 가 기존 표본 저장소와
|
||||
/// 같은 모양으로 기본값에 쓸 수 있어야 한다.
|
||||
const SampleNotificationRepository({List<AppNotification>? items})
|
||||
: _items = items;
|
||||
|
||||
final List<AppNotification>? _items;
|
||||
|
||||
static List<AppNotification> _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<List<AppNotification>> load({int limit = 50}) async =>
|
||||
(_items ?? _defaults()).take(limit).toList();
|
||||
|
||||
@override
|
||||
Future<void> markRead(String id) async {}
|
||||
}
|
||||
|
||||
/// 실 서버 구현 — `users/{uid}/notifications`.
|
||||
///
|
||||
/// 규칙이 본인 읽기와 `read`·`readAt` 두 필드 수정만 허용한다. 생성과 삭제는
|
||||
/// Functions(FCM) 경유라 앱에서 부를 경로 자체가 없다.
|
||||
class FirestoreNotificationRepository implements NotificationRepository {
|
||||
const FirestoreNotificationRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
@override
|
||||
Future<List<AppNotification>> 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,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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);
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import 'notification_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 알림 종류·표기·그룹화와 저장소는 `notification_data.dart` 에 있다.
|
||||
/// 이 화면을 임포트한 곳이 모델까지 함께 쓰므로 그대로 내보낸다.
|
||||
export 'notification_data.dart';
|
||||
|
||||
/// 알림 — 화면 대조 원장 F0 · Figma `229:11265`.
|
||||
class NotificationScreen extends StatefulWidget {
|
||||
const NotificationScreen({super.key, required this.repository});
|
||||
|
||||
final NotificationRepository repository;
|
||||
|
||||
@override
|
||||
State<NotificationScreen> createState() => _NotificationScreenState();
|
||||
}
|
||||
|
||||
class _NotificationScreenState extends State<NotificationScreen> {
|
||||
List<AppNotification>? _items;
|
||||
final Set<String> _read = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final items = await widget.repository.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_read
|
||||
..clear()
|
||||
..addAll(items.where((i) => i.read).map((i) => i.id));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final items = _items;
|
||||
if (items == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final groups = groupNotifications(items);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.homeNotificationsTooltip),
|
||||
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
|
||||
leading: BackButton(
|
||||
onPressed: () => Navigator.of(context).pop({..._read}),
|
||||
),
|
||||
),
|
||||
body: items.isEmpty
|
||||
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
children: [
|
||||
for (final group in groups)
|
||||
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
foregroundDecoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFF1A1A1A)),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
group.label,
|
||||
style: _style(_grey, height: 24 / 14),
|
||||
),
|
||||
for (final (i, item) in group.items.indexed) ...[
|
||||
SizedBox(height: i == 0 ? 20 : 24),
|
||||
_tile(item),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(AppNotification item) {
|
||||
final read = _read.contains(item.id);
|
||||
final (fill, icon) = _badge(item.kind);
|
||||
return InkWell(
|
||||
onTap: () => _open(item),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFF333333)),
|
||||
),
|
||||
child: ImageIcon(icon, size: 14, color: AppColors.onCosmic),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: _style(
|
||||
read ? AppColors.onCosmicVariant : AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${item.sourceLabel} · ${notificationTimestamp(item.at)}',
|
||||
style: _style(_grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 이동할 곳이 없는 종류에는 화살표를 그리지 않는다.
|
||||
if (item.kind.navigable) ...[
|
||||
const SizedBox(width: 13),
|
||||
const ImageIcon(
|
||||
AppIcons.chevronRight,
|
||||
size: 24,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(AppNotification item) async {
|
||||
// 처음 읽은 것만 서버에 알린다. 이미 읽은 알림을 다시 눌러도 쓰기가 없다.
|
||||
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 {
|
||||
// 읽음 표시가 실패해도 화면을 막지 않는다. 다음 진입에서 서버 값으로
|
||||
// 되돌아오므로 잃는 것이 없고, 여기서 막으면 알림 자체를 못 읽는다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _grey = Color(0xFF777777);
|
||||
|
||||
/// 시안 알림 글자 — 42dot 14/600 · 자간 -2.5% · 행간 130%.
|
||||
TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: height,
|
||||
letterSpacing: -0.35,
|
||||
color: color,
|
||||
);
|
||||
|
||||
/// 종류별 원 배지 — 시안 30 원 · #333333 획. 댓글·이클립스는 검정, 좋아요는 #ff5151,
|
||||
/// 아이템은 #cebf3b, 퀘스트는 #1a1a1c. 캠페인 달성은 시안이 없어 댓글과 같은 검정이다.
|
||||
(Color, ImageProvider) _badge(NotificationKind kind) => switch (kind) {
|
||||
NotificationKind.comment => (Colors.black, _icon('message_circle')),
|
||||
NotificationKind.campaign => (Colors.black, _icon('message_circle')),
|
||||
NotificationKind.like => (const Color(0xFFFF5151), _icon('heart')),
|
||||
NotificationKind.eclipse => (Colors.black, _icon('planet')),
|
||||
NotificationKind.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')),
|
||||
NotificationKind.quest => (
|
||||
AppColors.cosmicSurfaceContainer,
|
||||
_icon('book_open'),
|
||||
),
|
||||
};
|
||||
|
||||
AssetImage _icon(String name) => AssetImage('assets/icons/$name.png');
|
||||
Reference in New Issue
Block a user