From cf608a1d99ae7e23dd180edb153c5de557e8dcbd Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 22:48:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=ED=95=A8=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=83=9D=EC=84=B1=20=C2=B7=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=C2=B7=20=EB=B8=8C=EB=A6=AC=ED=95=91=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=8A=B9=EC=9D=B8=C2=B7=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=C2=B7=20=ED=91=B8=EC=8B=9C=20=ED=86=A0=ED=94=BD=EC=9D=84=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=8C=AC=EB=8D=A4=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../functions/src/briefing/briefing.ts | 70 ++++++++- app/firebase/functions/src/index.ts | 3 +- app/firebase/functions/src/notify/inbox.ts | 136 ++++++++++++++++++ app/firebase/functions/test/briefing.test.ts | 47 +++++- app/firebase/functions/test/inbox.test.ts | 69 +++++++++ app/lib/app.dart | 58 +++++--- app/lib/core/push/push_messaging.dart | 48 ++++--- app/lib/features/home/home_data.dart | 5 + .../home/home_repository_firestore.dart | 4 + .../features/home/widgets/briefing_list.dart | 92 +++++++++++- .../notification/notification_data.dart | 16 +++ .../notification/notification_screen.dart | 54 ++++--- app/lib/l10n/app_en.arb | 4 + app/lib/l10n/app_es.arb | 4 + app/lib/l10n/app_ja.arb | 4 + app/lib/l10n/app_ko.arb | 4 + app/lib/l10n/app_localizations.dart | 24 ++++ app/lib/l10n/app_localizations_en.dart | 12 ++ app/lib/l10n/app_localizations_es.dart | 12 ++ app/lib/l10n/app_localizations_ja.dart | 12 ++ app/lib/l10n/app_localizations_ko.dart | 12 ++ app/lib/l10n/app_localizations_zh.dart | 12 ++ app/lib/l10n/app_zh.arb | 4 + app/lib/l10n/remote_app_localizations.dart | 12 ++ app/test/backend_repository_test.dart | 14 ++ app/test/f0_screens_test.dart | 43 ++++++ app/test/home_screen_test.dart | 8 +- 27 files changed, 714 insertions(+), 69 deletions(-) create mode 100644 app/firebase/functions/src/notify/inbox.ts create mode 100644 app/firebase/functions/test/inbox.test.ts diff --git a/app/firebase/functions/src/briefing/briefing.ts b/app/firebase/functions/src/briefing/briefing.ts index e6c2b6c..1c6ecaf 100644 --- a/app/firebase/functions/src/briefing/briefing.ts +++ b/app/firebase/functions/src/briefing/briefing.ts @@ -48,8 +48,16 @@ export interface BriefingCandidate { articleUrl: string; /** AI 요약 본문. */ aiSummary: string; + /** + * 출처 구분 — PRD-06 「공식 출처와 팬덤 커뮤니티 출처를 구분 표시」 · 9/18 v1.5. + * 선택 필드다(수신 규격 확정 전 · 해석 확인 요청). 받으면 카드에 표시한다. + */ + sourceKind?: SourceKind; } +export const SOURCE_KINDS = ['official', 'fandom'] as const; +export type SourceKind = typeof SOURCE_KINDS[number]; + export interface IngestResult { accepted: number; rejected: Array<{ index: number; reason: string }>; @@ -116,9 +124,15 @@ export function validateCandidate( } } + const kind = text('sourceKind'); + if (kind !== null && !(SOURCE_KINDS as readonly string[]).includes(kind)) { + return { ok: false, reason: `sourceKind 는 ${SOURCE_KINDS.join('|')} 중 하나여야 합니다.` }; + } + return { ok: true, value: { + ...(kind ? { sourceKind: kind as SourceKind } : {}), fandomId, section: section as Section, title: required.title!, @@ -189,6 +203,7 @@ export const fnBriefingIngest = onCall(async (req) => { sourceUrl: candidate.sourceUrl, articleUrl: candidate.articleUrl, aiSummary: candidate.aiSummary, + ...(candidate.sourceKind ? { sourceKind: candidate.sourceKind } : {}), // B-75 「AI 요약 표시」 — 사람이 쓴 글이 아님을 화면이 밝힐 근거다. aiGenerated: true, // B-47 「AI Draft 자동게시 금지」 — 수신은 언제나 draft 로 끝난다. @@ -226,14 +241,20 @@ export const fnBriefingPublish = onCall(async (req) => { const role = requireRole(req, ['master', 'ops']); const fandomId = str(req.data, 'fandomId'); const day = str(req.data, 'dayKey'); + // 항목별 승인(상세정의 §12 「운영 검수 → 승인」) — `itemIds` 를 주면 그 초안만 게시한다. + const itemIds = itemIdsOf(req.data); const db = getFirestore(); - const drafts = await db.collection('briefings') + const all = await db.collection('briefings') .where('fandomId', '==', fandomId) .where('dayKey', '==', day) .where('status', '==', 'draft') .get(); - if (drafts.empty) { + const drafts = { docs: itemIds ? all.docs.filter((d) => itemIds.includes(d.id)) : all.docs }; + if (itemIds && drafts.docs.length !== itemIds.length) { + fail(ERR.permissionDenied, '이 팬덤 · 날짜의 초안이 아닌 항목이 있습니다.'); + } + if (drafts.docs.length === 0) { fail(ERR.permissionDenied, '게시할 브리핑 초안이 없습니다.'); } @@ -261,7 +282,7 @@ export const fnBriefingPublish = onCall(async (req) => { targetPath: `briefings/${fandomId}_${day}`, targetType: 'briefing', reason: 'BRIEFING_PUBLISH', - patch: { status: 'published', count: drafts.size, bySection }, + patch: { status: 'published', count: drafts.docs.length, bySection, itemIds: drafts.docs.map((d) => d.id) }, createdAt: FieldValue.serverTimestamp(), }); @@ -278,7 +299,48 @@ export const fnBriefingPublish = onCall(async (req) => { }); return { - fandomId, dayKey: day, published: drafts.size, bySection, + fandomId, dayKey: day, published: drafts.docs.length, bySection, pushed: pushed === true, }; }); + +function itemIdsOf(data: unknown): string[] | undefined { + const raw = (data as Record | null)?.itemIds; + if (raw === undefined) return undefined; + if (!Array.isArray(raw) || raw.length === 0 || raw.some((v) => typeof v !== 'string' || !v)) { + fail(ERR.permissionDenied, 'itemIds 는 비어 있지 않은 문자열 배열이어야 합니다.'); + } + return [...new Set(raw as string[])]; +} + +/** + * `fn-briefing-reject` — 항목별 반려(상세정의 §12 「운영 검수」 · 별첨 A §3 「Draft Review」). + * 반려 사유를 남기고 `rejected` 로 닫는다 — 게시 대상에서 빠지고 기록은 보존한다. + */ +export const fnBriefingReject = onCall(async (req) => { + const actorUid = callerUid(req); + const role = requireRole(req, ['master', 'ops']); + const itemIds = itemIdsOf(req.data); + if (!itemIds) fail(ERR.permissionDenied, '반려할 항목이 없습니다.'); + const reason = str(req.data, 'reason'); + const db = getFirestore(); + const refs = itemIds.map((id) => db.doc(`briefings/${id}`)); + await db.runTransaction(async (tx) => { + const snaps = await tx.getAll(...refs); + for (const snap of snaps) { + if (snap.get('status') !== 'draft') fail(ERR.permissionDenied, '초안만 반려할 수 있습니다.'); + } + for (const snap of snaps) { + tx.update(snap.ref, { + status: 'rejected', rejectReason: reason, + rejectedBy: actorUid, rejectedAt: FieldValue.serverTimestamp(), + }); + tx.create(db.collection('adminActionLogs').doc(), { + actorUid, role, targetPath: snap.ref.path, targetType: 'briefing', + reason: 'BRIEFING_REJECT', patch: { status: 'rejected', rejectReason: reason }, + createdAt: FieldValue.serverTimestamp(), + }); + } + }); + return { rejected: itemIds.length }; +}); diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 3fdc4f4..8a495d8 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -51,7 +51,8 @@ export { export { fnTranslationSubmit, fnTranslationMark, fnTranslationSettle, } from './account/translation.js'; -export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js'; +export { fnBriefingIngest, fnBriefingPublish, fnBriefingReject } from './briefing/briefing.js'; +export { fnInboxEclipse, fnInboxHeroDrop, fnInboxQuestDone } from './notify/inbox.js'; export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js'; export { fnPlanetHistory } from './fandom/history.js'; diff --git a/app/firebase/functions/src/notify/inbox.ts b/app/firebase/functions/src/notify/inbox.ts new file mode 100644 index 0000000..e637f11 --- /dev/null +++ b/app/firebase/functions/src/notify/inbox.ts @@ -0,0 +1,136 @@ +import { FieldValue, getFirestore } from 'firebase-admin/firestore'; +import { onDocumentCreated, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore'; +import { logger } from 'firebase-functions'; + +/** + * 알림함(`users/{uid}/notifications`) 항목 생성 — Figma `229:11265` 홈/알림 · 명세 `518:88353`. + * + * Figma 표본의 문구 · 출처 표기를 그대로 쓴다. 2차 범위(이클립스 · 퀘스트 · 영웅의 선물)만 + * 여기서 만든다. INSIDE · 캠페인 댓글 · 좋아요 알림은 3차(10/16) 범위다. + * - 「내 행성의 이클립스를 방어했어요.」 · 「내 행성의 이클립스가 발생했어요.」 행성명 → 이동 X + * - 「○○님의 고귀한 기여로 영웅의 선물이 발동되었습니다!」 아이템 → 이동 X + * - 「[퀘스트명] 퀘스트를 완료했어요!」 퀘스트 → 해당 퀘스트 상세 이동 + * + * 문서 필드: `eventType`(앱 이동 축) · `type`(B-77 구분) · `title` · `source` · `targetPath`. + * 문서 ID 를 사건마다 고정해 트리거 재전달이 같은 알림을 두 번 쌓지 않는다. + */ +export type InboxEvent = 'comment' | 'like' | 'campaign' | 'quest' | 'eclipse' | 'item_used'; + +export interface InboxItem { + eventType: InboxEvent; + type: 'essential' | 'fandomEvent' | 'community'; + title: string; + source: string; + targetPath?: string; +} + +export async function notifyOnce(uid: string, id: string, item: InboxItem): Promise { + try { + await getFirestore().doc(`users/${uid}/notifications/${id}`).create({ + ...item, + read: false, + createdAt: FieldValue.serverTimestamp(), + }); + return true; + } catch (error) { + // 이미 쌓은 알림(ALREADY_EXISTS = 6)은 정상이다. + if ((error as { code?: number }).code !== 6) { + logger.error('[inbox] 알림 기록 실패', { uid, id, message: (error as Error).message }); + } + return false; + } +} + +/** + * 팬덤 회원 전원에게 한 건씩. 이클립스 · 영웅의 선물처럼 「내 행성」 사건이다. + * ponytail: 회원을 한 번에 읽는다. 회원 수가 수십만이 되면 페이지 단위 + 작업 큐로. + */ +export async function notifyMembers(fandomId: string, id: string, item: InboxItem): Promise { + const db = getFirestore(); + const members = await db.collection(`fandoms/${fandomId}/members`).select().get(); + const writer = db.bulkWriter(); + writer.onWriteError((e) => e.code !== 6 && e.failedAttempts < 3); + for (const m of members.docs) { + writer.create(db.doc(`users/${m.id}/notifications/${id}`), { + ...item, read: false, createdAt: FieldValue.serverTimestamp(), + }).catch(() => undefined); + } + await writer.close(); + return members.size; +} + +/** 이클립스 회차 상태 전이 → 회원 전원. 발생(active 진입) · 방어(ended + success). */ +export async function notifyEclipseTransition( + fandomId: string, roundId: string, + before: { status?: unknown }, after: { status?: unknown; result?: unknown }, +): Promise<'started' | 'defended' | null> { + const started = before.status !== 'active' && after.status === 'active'; + const defended = before.status !== 'ended' && after.status === 'ended' && after.result === 'success'; + if (!started && !defended) return null; + const name = ((await getFirestore().doc(`fandoms/${fandomId}`).get()).get('name') as string | undefined) ?? ''; + await notifyMembers(fandomId, `eclipse_${fandomId}_${roundId}_${started ? 'start' : 'defended'}`, { + eventType: 'eclipse', type: 'fandomEvent', + title: started ? '내 행성의 이클립스가 발생했어요.' : '내 행성의 이클립스를 방어했어요.', + source: name, + }); + return started ? 'started' : 'defended'; +} + +/** 영웅의 선물 발동 → 회원 전원. */ +export async function notifyHeroDrop(fandomId: string, dropId: string, heroNickname: unknown): Promise { + const hero = typeof heroNickname === 'string' && heroNickname ? heroNickname : '팬'; + return notifyMembers(fandomId, `herodrop_${fandomId}_${dropId}`, { + eventType: 'item_used', type: 'fandomEvent', + title: `${hero}님의 고귀한 기여로 영웅의 선물이 발동되었습니다!`, source: '아이템', + }); +} + +/** + * 퀘스트 완료 → 본인. 진행 문서(`questProgress`)는 완료 처리 트랜잭션에서만 횟수가 오른다 + * (`progressWriter`). 같은 날 횟수가 늘었거나 새 날의 첫 완료일 때 한 건. + */ +export async function notifyQuestDone( + uid: string, questId: string, + before: { dayKey?: unknown; count?: unknown } | undefined, + after: { dayKey?: unknown; count?: unknown }, +): Promise { + const count = typeof after.count === 'number' ? after.count : 0; + if (count < 1) return false; + if (before && before.dayKey === after.dayKey && (before.count as number | undefined ?? 0) >= count) return false; + const quest = await getFirestore().doc(`quests/${questId}`).get(); + const title = (quest.get('title') as string | undefined) ?? ''; + return notifyOnce(uid, `quest_${questId}_${String(after.dayKey)}_${count}`, { + eventType: 'quest', type: 'essential', + title: `[${title}] 퀘스트를 완료했어요!`, source: '퀘스트', + targetPath: `quests/${questId}`, + }); +} + +export const fnInboxEclipse = onDocumentUpdated( + { document: 'fandoms/{fandomId}/eclipseRounds/{roundId}', retry: true }, + async (event) => { + const before = event.data?.before.data() ?? {}; + const after = event.data?.after.data() ?? {}; + await notifyEclipseTransition(event.params.fandomId, event.params.roundId, before, after); + }, +); + +export const fnInboxHeroDrop = onDocumentCreated( + { document: 'fandoms/{fandomId}/heroDrops/{dropId}', retry: true }, + async (event) => { + await notifyHeroDrop(event.params.fandomId, event.params.dropId, event.data?.get('heroNickname')); + }, +); + +export const fnInboxQuestDone = onDocumentWritten( + { document: 'users/{uid}/questProgress/{questId}', retry: true }, + async (event) => { + const after = event.data?.after; + if (!after?.exists) return; + const before = event.data?.before; + await notifyQuestDone( + event.params.uid, event.params.questId, + before?.exists ? before.data() : undefined, after.data() ?? {}, + ); + }, +); diff --git a/app/firebase/functions/test/briefing.test.ts b/app/firebase/functions/test/briefing.test.ts index 0abe783..dff3b51 100644 --- a/app/firebase/functions/test/briefing.test.ts +++ b/app/firebase/functions/test/briefing.test.ts @@ -4,7 +4,7 @@ import type { CallableRequest } from 'firebase-functions/v2/https'; import { bootAdmin, shutdownAdmin } from './support.js'; import { - candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate, + candidateDocId, fnBriefingIngest, fnBriefingPublish, fnBriefingReject, validateCandidate, } from '../src/briefing/briefing.js'; import { dayKey } from '../src/core/time.js'; import { fandomTopic, notifyEclipseWarning } from '../src/notify/push.js'; @@ -276,6 +276,51 @@ describe('승인 — fn-briefing-publish (export 직접 호출)', () => { }); }); +describe('항목별 검수 — 승인 · 반려(상세정의 §12)', () => { + const day = () => dayKey(new Date()); + async function ids(): Promise { + await fnBriefingIngest.run(req('ops', { + items: [ + candidate({ sourceUrl: 'https://example.com/i/1' }), + candidate({ sourceUrl: 'https://example.com/i/2' }), + candidate({ section: 'SNS', sourceUrl: 'https://example.com/i/3', sourceKind: 'fandom' }), + ], + })); + const docs = await getFirestore().collection('briefings').where('fandomId', '==', FANDOM).get(); + return docs.docs.map((d) => d.id).sort(); + } + + it('출처 구분은 선택 필드 — 받으면 저장하고, 정해진 값이 아니면 거부한다', async () => { + expect(validateCandidate(candidate({ sourceKind: 'blog' })).ok).toBe(false); + const [, , third] = await ids(); + const kinds = (await getFirestore().collection('briefings').where('fandomId', '==', FANDOM).get()) + .docs.map((d) => d.get('sourceKind')); + expect(kinds.filter((k) => k === 'fandom')).toHaveLength(1); + expect(third).toBeTruthy(); + }); + + it('고른 항목만 게시하고, 반려는 사유와 함께 닫고 다시 게시되지 않는다', async () => { + const [a, b, c] = await ids(); + await fnBriefingReject.run(req('ops', { itemIds: [b], reason: '출처 불명확' })); + const result = await fnBriefingPublish.run(req('ops', { fandomId: FANDOM, dayKey: day(), itemIds: [a] })); + expect(result.published).toBe(1); + + const db = getFirestore(); + expect((await db.doc(`briefings/${a}`).get()).get('status')).toBe('published'); + const rejected = await db.doc(`briefings/${b}`).get(); + expect(rejected.get('status')).toBe('rejected'); + expect(rejected.get('rejectReason')).toBe('출처 불명확'); + expect((await db.doc(`briefings/${c}`).get()).get('status')).toBe('draft'); + // 반려 · 게시된 항목은 다시 고를 수 없다. + await expect(fnBriefingPublish.run(req('ops', { fandomId: FANDOM, dayKey: day(), itemIds: [b] }))) + .rejects.toThrowError('초안이 아닌 항목'); + await expect(fnBriefingReject.run(req('ops', { itemIds: [a], reason: 'x' }))) + .rejects.toThrowError('초안만 반려'); + await expect(fnBriefingReject.run(req('viewer', { itemIds: [c], reason: 'x' }))) + .rejects.toThrowError('권한이 없습니다.'); + }); +}); + describe('당일 필터 — 앱이 읽는 질의', () => { it('게시된 당일분만 고르고 초안과 전일분은 제외한다', async () => { const db = getFirestore(); diff --git a/app/firebase/functions/test/inbox.test.ts b/app/firebase/functions/test/inbox.test.ts new file mode 100644 index 0000000..6e066c4 --- /dev/null +++ b/app/firebase/functions/test/inbox.test.ts @@ -0,0 +1,69 @@ +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { getFirestore } from 'firebase-admin/firestore'; + +import { notifyEclipseTransition, notifyHeroDrop, notifyQuestDone } from '../src/notify/inbox.js'; +import { bootAdmin, shutdownAdmin } from './support.js'; + +/** 알림함 항목 — Figma `229:11265` 표본 문구 · 명세 `518:88353` 이동 규칙의 서버 절반. */ + +const FANDOM = 'test-inbox-fandom'; +const A = 'test-inbox-a'; +const B = 'test-inbox-b'; + +async function inbox(uid: string) { + const snap = await getFirestore().collection(`users/${uid}/notifications`).get(); + return snap.docs.map((d) => d.data()); +} + +async function wipe() { + const db = getFirestore(); + for (const uid of [A, B]) await db.recursiveDelete(db.doc(`users/${uid}`)); + await db.recursiveDelete(db.doc(`fandoms/${FANDOM}`)); + await db.doc('quests/test-inbox-quest').delete(); +} + +beforeAll(async () => { + bootAdmin(); + await wipe(); + const db = getFirestore(); + await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS' }); + for (const uid of [A, B]) await db.doc(`fandoms/${FANDOM}/members/${uid}`).set({ joinedAt: new Date() }); + await db.doc('quests/test-inbox-quest').set({ title: '만보기' }); +}); +afterAll(async () => { await wipe(); await shutdownAdmin(); }); + +it('이클립스 발생 · 방어는 회원 전원에게 한 건씩, 재전달해도 늘지 않는다', async () => { + expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started'); + expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started'); + expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'ended', result: 'success' })).toBe('defended'); + // 실패 종료 · 상태 변화 없음은 알리지 않는다. + expect(await notifyEclipseTransition(FANDOM, 'r2', { status: 'active' }, { status: 'ended', result: 'failed' })).toBeNull(); + expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'active' })).toBeNull(); + + for (const uid of [A, B]) { + const items = await inbox(uid); + const titles = items.map((n) => n.title).sort(); + expect(titles).toEqual(['내 행성의 이클립스가 발생했어요.', '내 행성의 이클립스를 방어했어요.']); + expect(items.every((n) => n.eventType === 'eclipse' && n.source === 'BTS' && !n.targetPath)).toBe(true); + } +}); + +it('영웅의 선물 발동은 회원 전원에게 발동자 닉네임으로', async () => { + await notifyHeroDrop(FANDOM, 'd1', '팜하니짱'); + const hero = (await inbox(B)).filter((n) => n.eventType === 'item_used'); + expect(hero).toHaveLength(1); + expect(hero[0]!.title).toBe('팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!'); + expect(hero[0]!.source).toBe('아이템'); +}); + +it('퀘스트 완료는 횟수가 오른 때만 본인에게, 상세 경로를 싣는다', async () => { + const day = '2026-10-05'; + expect(await notifyQuestDone(A, 'test-inbox-quest', undefined, { dayKey: day, count: 1 })).toBe(true); + // 같은 횟수 재기록(updatedAt 만 바뀜)은 알리지 않는다. + expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 1 })).toBe(false); + expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 2 })).toBe(true); + const quest = (await inbox(A)).filter((n) => n.eventType === 'quest'); + expect(quest).toHaveLength(2); + expect(quest[0]!.title).toBe('[만보기] 퀘스트를 완료했어요!'); + expect(quest[0]!.targetPath).toBe('quests/test-inbox-quest'); +}); diff --git a/app/lib/app.dart b/app/lib/app.dart index 42f79e7..48fa692 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -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 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 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, + ), ), ), ), diff --git a/app/lib/core/push/push_messaging.dart b/app/lib/core/push/push_messaging.dart index 1969a34..dad4c42 100644 --- a/app/lib/core/push/push_messaging.dart +++ b/app/lib/core/push/push_messaging.dart @@ -32,8 +32,8 @@ class PushMessaging { StreamSubscription? _opened; StreamSubscription? _tokenRefresh; - /// 소속 팬덤. 토픽 이름의 일부라 매번 사용자 문서를 읽지 않도록 기억한다. - String? _fandomId; + /// 가입한 팬덤 전부. 토픽 이름의 일부라 매번 읽지 않도록 기억한다. + Set? _fandomIds; /// 알림 권한 상태. 화면이 「거부됨」 안내와 재요청 버튼을 그릴 근거다. AuthorizationStatus _status = AuthorizationStatus.notDetermined; @@ -80,16 +80,25 @@ class PushMessaging { }, SetOptions(merge: true)); } - /// 소속 팬덤 ID. 한 번 읽어 두고 다시 읽지 않는다. - Future _resolveFandomId() async { - final cached = _fandomId; + /// 가입한 팬덤 ID 전부 — 대표 팬덤(`users.fandomId`) + 가입 목록(`users/{uid}/memberships`). + /// + /// PRD-06 「관심 아티스트를 등록한 사용자」 · 별첨 1 ③-5 「FCM 푸시 수신」 — 대표 팬덤 + /// 하나만 구독하면 다른 행성의 브리핑 · 이클립스 예고를 받지 못한다. + Future> _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 {}).strOr('fandomId'); - if (id.isNotEmpty) _fandomId = id; - return id; + final memberships = await _backend.db + .collection('users/$uid/memberships') + .get(); + final ids = { + (user.data() ?? const {}).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 syncTopics(Map 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 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 dispose() async { diff --git a/app/lib/features/home/home_data.dart b/app/lib/features/home/home_data.dart index 05c1080..9966761 100644 --- a/app/lib/features/home/home_data.dart +++ b/app/lib/features/home/home_data.dart @@ -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 — 진행 중인 캠페인 한 건. diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index f6b63a5..301ffce 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -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, + }, ); } diff --git a/app/lib/features/home/widgets/briefing_list.dart b/app/lib/features/home/widgets/briefing_list.dart index 9a0afb8..dfd9c49 100644 --- a/app/lib/features/home/widgets/briefing_list.dart +++ b/app/lib/features/home/widgets/briefing_list.dart @@ -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 showBriefingDetail( + BuildContext context, + BriefingItem item, { + required Future 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( + 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), + ), + ], + ), + ), + ), + ); +} diff --git a/app/lib/features/notification/notification_data.dart b/app/lib/features/notification/notification_data.dart index 0d78c2b..73ceb26 100644 --- a/app/lib/features/notification/notification_data.dart +++ b/app/lib/features/notification/notification_data.dart @@ -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, + }, ), ]; } diff --git a/app/lib/features/notification/notification_screen.dart b/app/lib/features/notification/notification_screen.dart index 4a90d82..e0bffca 100644 --- a/app/lib/features/notification/notification_screen.dart +++ b/app/lib/features/notification/notification_screen.dart @@ -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(); + + @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 { ); } + /// 명세 `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 { ], ), ), - // 이동할 곳이 없는 종류에는 화살표를 그리지 않는다. - if (item.kind.navigable) ...[ + // 이동할 곳이 없는 알림에는 화살표를 그리지 않는다. + if (_screenFor(item) != null) ...[ const SizedBox(width: 13), const ImageIcon( AppIcons.chevronRight, @@ -152,23 +180,15 @@ class _NotificationScreenState extends State { // 처음 읽은 것만 서버에 알린다. 이미 읽은 알림을 다시 눌러도 쓰기가 없다. 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(MaterialPageRoute(builder: (_) => screen)); } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index b24386f..e2b4410 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 808d616..cb2e291 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index a6549b0..93ae1db 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -261,6 +261,10 @@ "planetCreateConfirm": "選択したオプションで\n惑星を作成しますか?", "commonCreate": "作成", "briefingOpenFailed": "原文を開けません。", + "briefingAiSummary": "AI要約", + "briefingViewOriginal": "原文を見る", + "briefingSourceOfficial": "公式ソース", + "briefingSourceFandom": "ファンダムソース", "campaignAchieved": "{percent}% 達成", "campaignView": "キャンペーンを見る", "growthFinalReached": "最終ステージに到達しました", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 28b8a4c..016946f 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -612,6 +612,10 @@ "planetCreateConfirm": "선택한 옵션으로\n행성을 생성할까요?", "commonCreate": "생성", "briefingOpenFailed": "원문을 열 수 없습니다.", + "briefingAiSummary": "AI 요약", + "briefingViewOriginal": "원문 보기", + "briefingSourceOfficial": "공식 출처", + "briefingSourceFandom": "팬덤 출처", "campaignAchieved": "{percent}% 달성", "@campaignAchieved": { "placeholders": { diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index a03ce1b..75d6184 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 202580e..afff113 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index db2f46f..44ab10c 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 2b979bb..3b1448f 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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% 達成'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 890d8d3..3e29dfb 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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% 달성'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index b87b4e9..7cb3e47 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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%'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index fdbead2..5680e94 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -261,6 +261,10 @@ "planetCreateConfirm": "要用所选选项\n创建星球吗?", "commonCreate": "创建", "briefingOpenFailed": "无法打开原文。", + "briefingAiSummary": "AI 摘要", + "briefingViewOriginal": "查看原文", + "briefingSourceOfficial": "官方来源", + "briefingSourceFandom": "粉丝圈来源", "campaignAchieved": "已达成 {percent}%", "campaignView": "查看活动", "growthFinalReached": "已到达最终阶段", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index b8af19c..3b53eec 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index 62ffab0..617998e 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -1473,6 +1473,20 @@ void main() { ); }); + test('가입한 팬덤 전부의 토픽을 맞춘다 — 대표 팬덤 하나만이 아니다(C-34)', () async { + final db = await _seed(); + await db.doc('users/$_uid/memberships/nj').set({'fandomId': 'nj'}); + final transport = _FakeTransport(); + await PushMessaging( + backend: _backend(db), + transport: transport, + ).syncTopics(PushTopic.defaults); + expect( + transport.subscribed, + containsAll(['fandom_bts_briefing', 'fandom_nj_briefing']), + ); + }); + test('로그아웃 정리는 전 구분을 해지하고 토큰을 지운다', () async { final db = await _seed(); final transport = _FakeTransport(); diff --git a/app/test/f0_screens_test.dart b/app/test/f0_screens_test.dart index 8a4deec..86f9273 100644 --- a/app/test/f0_screens_test.dart +++ b/app/test/f0_screens_test.dart @@ -176,6 +176,49 @@ void main() { expect(returned, contains('n1')); }); + testWidgets('누르면 스낵바가 아니라 대상 화면으로 간다 — 이클립스는 경로가 있어도 이동 X', (tester) async { + final opened = []; + final now = DateTime.now(); + await pumpWide( + tester, + NotificationTargets( + screenFor: (path) { + opened.add(path); + return Scaffold(body: Text('열린 화면 $path')); + }, + child: NotificationScreen( + repository: SampleNotificationRepository( + items: [ + AppNotification( + id: 'q', + kind: NotificationKind.quest, + title: '[만보기] 퀘스트를 완료했어요!', + at: now, + targetPath: 'quests/steps-1', + ), + AppNotification( + id: 'e', + kind: NotificationKind.eclipse, + title: '내 행성의 이클립스가 발생했어요.', + at: now, + targetPath: 'fandoms/bts', + ), + ], + ), + ), + ), + ); + await tester.tap(find.text('내 행성의 이클립스가 발생했어요.')); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + expect(find.textContaining('열린 화면'), findsNothing); + + await tester.tap(find.text('[만보기] 퀘스트를 완료했어요!')); + await tester.pumpAndSettle(); + expect(find.text('열린 화면 quests/steps-1'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + }); + testWidgets('비어 있으면 그렇게 알린다', (tester) async { await pumpWide( tester, diff --git a/app/test/home_screen_test.dart b/app/test/home_screen_test.dart index f776a8a..dd76701 100644 --- a/app/test/home_screen_test.dart +++ b/app/test/home_screen_test.dart @@ -75,6 +75,7 @@ BriefingItem _brief( sourceUrl: 'https://example.com/a', articleUrl: 'https://example.com/a', aiSummary: '요약입니다.', + sourceKind: 'official', ); /// 브리핑만 갈아 끼운 표본. 「0 건이면 블록 전체 숨김」을 그 한 값으로 본다. @@ -355,7 +356,7 @@ void main() { expect(find.text('NEWS'), findsNothing); }); - testWidgets('줄을 누르면 원문보기로 나간다', (tester) async { + testWidgets('줄을 누르면 B-75 5 요소 상세가 뜨고, 원문 보기로 나간다', (tester) async { final opened = []; tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; @@ -371,6 +372,11 @@ void main() { )); await tester.tap(find.text('원문이 있는 줄')); await tester.pumpAndSettle(); + expect(opened, isEmpty); + expect(find.text('AI 요약'), findsOneWidget); + expect(find.textContaining('공식 출처 · '), findsOneWidget); + await tester.tap(find.text('원문 보기')); + await tester.pumpAndSettle(); expect(opened, ['https://example.com/a']); }); }