알림함 항목 생성 · 알림 이동 · 브리핑 항목 승인·상세 · 푸시 토픽을 가입 팬덤 전체로
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user