알림함 항목 생성 · 알림 이동 · 브리핑 항목 승인·상세 · 푸시 토픽을 가입 팬덤 전체로
This commit is contained in:
@@ -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<string, unknown> | 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 };
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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() ?? {},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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<string[]> {
|
||||
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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,6 +227,8 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
// 캠페인 상세 · 라운지 글 상세처럼 여러 경로로 열리는 화면이 번역 저장소를 찾는 곳.
|
||||
child: TranslationScope(
|
||||
repository: _repos.translation,
|
||||
child: NotificationTargets(
|
||||
screenFor: _notificationScreen,
|
||||
child: MaterialApp.router(
|
||||
title: 'FANUZU',
|
||||
builder: (context, child) =>
|
||||
@@ -232,5 +251,6 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 +111,7 @@ class PushMessaging {
|
||||
/// 팬덤 ID 는 사용자 문서에서 스스로 읽는다. 호출자가 넘기게 하면 설정 화면이
|
||||
/// 그 값을 알아야 하고, 화면이 알 이유가 없다.
|
||||
Future<void> syncTopics(Map<PushTopic, bool> prefs) async {
|
||||
final fandomId = await _resolveFandomId();
|
||||
if (fandomId.isEmpty) return;
|
||||
for (final fandomId in await _resolveFandomIds()) {
|
||||
final plan = topicPlan(fandomId, prefs);
|
||||
for (final topic in plan.subscribe) {
|
||||
await _transport.subscribeToTopic(topic);
|
||||
@@ -112,6 +120,7 @@ class PushMessaging {
|
||||
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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -261,6 +261,10 @@
|
||||
"planetCreateConfirm": "選択したオプションで\n惑星を作成しますか?",
|
||||
"commonCreate": "作成",
|
||||
"briefingOpenFailed": "原文を開けません。",
|
||||
"briefingAiSummary": "AI要約",
|
||||
"briefingViewOriginal": "原文を見る",
|
||||
"briefingSourceOfficial": "公式ソース",
|
||||
"briefingSourceFandom": "ファンダムソース",
|
||||
"campaignAchieved": "{percent}% 達成",
|
||||
"campaignView": "キャンペーンを見る",
|
||||
"growthFinalReached": "最終ステージに到達しました",
|
||||
|
||||
@@ -612,6 +612,10 @@
|
||||
"planetCreateConfirm": "선택한 옵션으로\n행성을 생성할까요?",
|
||||
"commonCreate": "생성",
|
||||
"briefingOpenFailed": "원문을 열 수 없습니다.",
|
||||
"briefingAiSummary": "AI 요약",
|
||||
"briefingViewOriginal": "원문 보기",
|
||||
"briefingSourceOfficial": "공식 출처",
|
||||
"briefingSourceFandom": "팬덤 출처",
|
||||
"campaignAchieved": "{percent}% 달성",
|
||||
"@campaignAchieved": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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% 達成';
|
||||
|
||||
@@ -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% 달성';
|
||||
|
||||
@@ -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%';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -176,6 +176,49 @@ void main() {
|
||||
expect(returned, contains('n1'));
|
||||
});
|
||||
|
||||
testWidgets('누르면 스낵바가 아니라 대상 화면으로 간다 — 이클립스는 경로가 있어도 이동 X', (tester) async {
|
||||
final opened = <String>[];
|
||||
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,
|
||||
|
||||
@@ -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 = <String>[];
|
||||
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']);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user