From 4bb92c2ee7837fb0111a04dc9589845ab0fdd33a Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 22:38:42 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=8B=B0?= =?UTF-8?q?=EC=BB=A4=20=C2=B7=20=EA=B0=A4=EB=9F=AD=EC=8B=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=83=89=EC=9D=B8=20=C2=B7=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EC=8A=A4=EB=83=85=EC=83=B7=20=C2=B7=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=20ZAP=20=EC=83=81=ED=95=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=96=89=EC=84=B1=20Zap=20=C2=B7=20?= =?UTF-8?q?=EC=84=B1=EC=9A=B4=20=EB=AA=A9=ED=91=9C=20=C2=B7=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EA=B0=B1=EC=8B=A0=20=EC=95=88=EB=82=B4=20=C2=B7=20?= =?UTF-8?q?=ED=96=89=EC=84=B1=20=ED=84=B0=EC=B9=98=20=EB=B0=98=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/firestore.rules | 13 +++ app/firebase/functions/src/core/config.ts | 10 ++ app/firebase/functions/src/fandom/fandom.ts | 8 +- app/firebase/functions/src/fandom/weekly.ts | 58 +++++++++++ app/firebase/functions/src/index.ts | 1 + app/firebase/functions/src/ledger/pop.ts | 14 ++- app/firebase/functions/test/eclipse.test.ts | 11 ++- app/firebase/functions/test/growth.test.ts | 21 +++- app/firebase/functions/test/weekly.test.ts | 44 +++++++++ app/lib/core/data/planet_visual_state.dart | 21 ++++ app/lib/core/data/repositories.dart | 1 + app/lib/core/ui/live_planet.dart | 1 + app/lib/core/ui/planet_25d.dart | 7 +- .../features/eclipse/widgets/live_proof.dart | 7 +- app/lib/features/galaxy/galaxy_data.dart | 24 +++++ .../galaxy/galaxy_repository_firestore.dart | 35 ++++++- app/lib/features/galaxy/galaxy_screen.dart | 10 +- .../features/galaxy/galaxy_search_screen.dart | 95 +++++++++++++++---- .../features/galaxy/widgets/zap_feedback.dart | 9 ++ app/lib/features/home/home_screen.dart | 20 ++++ app/lib/features/home/widgets/home_hero.dart | 57 ++++++++++- .../features/planet/fpi_ranking_screen.dart | 14 +++ app/lib/features/planet/planet_data.dart | 27 +++++- .../features/planet/planet_detail_screen.dart | 38 ++++++-- .../planet/planet_repository_firestore.dart | 31 ++++++ 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 | 24 ++++- app/lib/l10n/app_localizations.dart | 16 +++- app/lib/l10n/app_localizations_en.dart | 14 ++- app/lib/l10n/app_localizations_es.dart | 14 ++- app/lib/l10n/app_localizations_ja.dart | 14 ++- app/lib/l10n/app_localizations_ko.dart | 14 ++- app/lib/l10n/app_localizations_zh.dart | 14 ++- app/lib/l10n/app_zh.arb | 4 +- app/lib/l10n/remote_app_localizations.dart | 8 +- app/test/backend_repository_test.dart | 35 +++++++ app/test/eclipse_live_test.dart | 8 +- app/test/eclipse_stubs.dart | 4 + app/test/f2_screens_test.dart | 3 + app/test/galaxy_zap_fx_test.dart | 19 +++- app/test/planet_fx_test.dart | 10 ++ app/test/planet_growth_status_test.dart | 15 +++ 44 files changed, 727 insertions(+), 78 deletions(-) create mode 100644 app/firebase/functions/src/fandom/weekly.ts create mode 100644 app/firebase/functions/test/weekly.test.ts diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 1ff2fcf..004268c 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -182,6 +182,19 @@ service cloud.firestore { allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용 } + // 주간 공식 Ranking Snapshot — 영구 Archive(별첨 B 「Ranking Snapshot」 B-20). 서버만 만들고 고치지 않는다. + match /rankingSnapshots/{week} { + allow read: if signedIn(); + allow write: if false; + } + + // 팬덤 이름 색인(`fn-nebula-create` 가 문서 ID = 소문자 이름으로 쓴다). 갤럭시 전체 검색(Figma `353:217832`)이 + // 접두 조회한다. 값은 공개 정보(팬덤 ID)뿐이다. + match /fandomNames/{name} { + allow read: if signedIn(); + allow write: if false; + } + // 어뷰징 탐지 임계치·판정 로직. 노출되면 우회가 가능해 클라이언트 접근을 전면 차단한다. // 근거: 정책설계서 P-06 「규칙은 공개하지 않는 것을 제안」 match /configPrivate/{configId} { diff --git a/app/firebase/functions/src/core/config.ts b/app/firebase/functions/src/core/config.ts index a62b7d7..aae4d95 100644 --- a/app/firebase/functions/src/core/config.ts +++ b/app/firebase/functions/src/core/config.ts @@ -43,6 +43,14 @@ export interface PolicyConfig { stepTiers: StepTier[]; /** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */ zapPop: number; + /** + * 연속 ZAP Rate Limit — UX·FX v1.5 §3 「연속 ZAP … Rate Limit 적용」 · PRD-03 「반복 클릭 FPI 제외」. + * 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A Q-A12): 수치는 자료에 없다. 한 호출(앱이 연타를 + * 묶음)의 최대 Zap 횟수와 같은 사용자의 호출 간 최소 간격(ms). 앱은 연타를 450ms 창으로 묶어 보내므로 + * 간격 400ms 는 정상 연타를 막지 않는다(`zap_controller.dart`). + */ + zapMaxPerCall: number; + zapMinIntervalMs: number; /** FPI 집계 창 — 설계서 §4.2 최근 7일 Rolling Window. */ fpiWindowDays: number; /** @@ -179,6 +187,8 @@ export const DEFAULT_POLICY: PolicyConfig = { { steps: 15_000, pop: 8 }, ], zapPop: 10, + zapMaxPerCall: 50, + zapMinIntervalMs: 400, fpiWindowDays: 7, // 단순 합산이 초기 정책이므로 전부 1.0 이다. fpiWeights: { diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts index 5fb72f3..50c62c8 100644 --- a/app/firebase/functions/src/fandom/fandom.ts +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -187,10 +187,13 @@ export async function defendEclipse(input: { qualifiedParticipants: FieldValue.increment(qualified ? 1 : 0), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); + } + // 라이브 소셜 프루프는 **참여 합류 1회**만 — 개별 Shake 횟수를 방송하지 않는다(UX·FX v1.5 §1.1 · §3.1 · §5.1 + // 「다른 팬의 행동은 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」). 집약 수는 화면의 유효 참여자 표시. + if (accepted > 0 && myTotal === 0) { tx.create(tickerRef, { kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid, nickname: publicNameOf(user), - defense: accepted, linkTo: `/eclipse/${fandomId}/${eclipseId}`, priority: TICKER_PRIORITY[TICKER_KIND_ECLIPSE_DEFENSE] ?? 50, createdAt: FieldValue.serverTimestamp(), @@ -1032,7 +1035,8 @@ export async function aggregateRankings(now = Date.now()): Promise<{ fandoms: nu for (const axis of FPI_AXES) total[axis] += patch.fpiAxes[axis]; totals.set(code, total); } - void writer.update(fandom.ref, { memberRanked: true }); + // 사용자 랭킹 화면의 「마지막 갱신」 — 별첨 1 ② 예외처리. + void writer.update(fandom.ref, { memberRanked: true, memberRankedAt: FieldValue.serverTimestamp() }); memberTotal += members.size; } await writer.close(); diff --git a/app/firebase/functions/src/fandom/weekly.ts b/app/firebase/functions/src/fandom/weekly.ts new file mode 100644 index 0000000..7bb22b0 --- /dev/null +++ b/app/firebase/functions/src/fandom/weekly.ts @@ -0,0 +1,58 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler'; +import { logger } from 'firebase-functions'; +import { FieldValue, getFirestore } from 'firebase-admin/firestore'; + +import { rankWithTies } from '../ledger/fpi.js'; + +/** + * Weekly Ranking Snapshot — 별첨 B 「Ranking Snapshot — 주 1 회 Snapshot 영구 Archive」(B-20) · + * 「Weekly Ranking — 주간 공식 Snapshot 은 영구 Archive」 · 9/16 EC06 「주간 Snapshot 유지」. + * + * - 모집단 · 순위는 FPI 랭킹과 같다(별첨 B 「FPI 랭킹」 — 정식 행성 중 최근 7일 유효 활동, 1·1·3 공동 순위). + * - **영구 Archive** — `create` 라 한 주에 한 번만 생기고 다시 쓰지 않는다. 규칙도 클라이언트 쓰기를 막는다. + * - Ranking Campaign Draft 자동 생성 · Admin 승인 게시는 Campaign 범위(3차)라 여기서 하지 않는다. + * + * 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A Q-A11): 스냅샷 시각은 자료에 없다. KST 월요일 00:10 + * (일일 마감 00:00 뒤)으로 두고, 그 시점의 집계값을 지난주(월~일) 공식 순위로 남긴다. + */ +export function weekKeyOf(at: Date): string { + // KST 기준 ISO 주차. 월요일 00:10 에 돌면 「방금 끝난 주」를 가리키도록 하루 전으로 계산한다. + const kst = new Date(at.getTime() + 9 * 3_600_000 - 86_400_000); + const day = (kst.getUTCDay() + 6) % 7; + const thursday = new Date(Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate() - day + 3)); + const firstThursday = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4)); + const week = 1 + Math.round(((thursday.getTime() - firstThursday.getTime()) / 86_400_000 + - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7); + return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; +} + +export async function snapshotWeeklyRanking(now = new Date()): Promise<{ week: string; created: boolean }> { + const db = getFirestore(); + const week = weekKeyOf(now); + const fandoms = await db.collection('fandoms').where('rankingEligible', '==', true) + .select('name', 'fpiScore', 'fpiAxes', 'stage', 'level').get(); + const planets = rankWithTies(fandoms.docs, (d) => Number(d.get('fpiScore') ?? 0), (d) => d.id) + .map(({ item, rank }) => ({ + fandomId: item.id, name: item.get('name') ?? item.id, rank, + fpiScore: Number(item.get('fpiScore') ?? 0), fpiAxes: item.get('fpiAxes') ?? {}, + stage: item.get('stage') ?? null, level: item.get('level') ?? null, + })); + try { + await db.doc(`rankingSnapshots/${week}`).create({ + week, kind: 'weekly_fpi', planets, population: planets.length, + createdAt: FieldValue.serverTimestamp(), + }); + return { week, created: true }; + } catch (error) { + const code = (error as { code?: number | string }).code; + if (code !== 6 && code !== 'already-exists') throw error; + return { week, created: false }; + } +} + +export const fnWeeklyRankingSnapshot = onSchedule( + { schedule: '10 0 * * 1', timeZone: 'Asia/Seoul' }, + async () => { + logger.info('[ranking] 주간 Snapshot', await snapshotWeeklyRanking()); + }, +); diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 5012e66..2f54c75 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -52,4 +52,5 @@ export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js'; export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js'; export { fnPlanetHistory } from './fandom/history.js'; +export { fnWeeklyRankingSnapshot } from './fandom/weekly.js'; export { fnPassportOnUserUpdate, fnPassportOnMemberWrite, fnPassportDaily } from './account/passport.js'; diff --git a/app/firebase/functions/src/ledger/pop.ts b/app/firebase/functions/src/ledger/pop.ts index 32cbc02..e0c87a3 100644 --- a/app/firebase/functions/src/ledger/pop.ts +++ b/app/firebase/functions/src/ledger/pop.ts @@ -1,6 +1,6 @@ import { onCall } from 'firebase-functions/v2/https'; import { onSchedule } from 'firebase-functions/v2/scheduler'; -import { getFirestore } from 'firebase-admin/firestore'; +import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions'; import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js'; @@ -66,11 +66,14 @@ export const fnPopSpend = onCall(async (req) => { fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.'); } const amount = positiveInt(num(req.data, 'amount'), 'amount'); - const { zapPop } = await loadPolicy(); + const { zapPop, zapMaxPerCall, zapMinIntervalMs } = await loadPolicy(); const count = Math.floor(amount / zapPop); if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) { fail(ERR.popVerifyFailed, `Zap 은 ${zapPop} POP 단위로만 보낼 수 있습니다.`); } + // 연속 ZAP Rate Limit(FX §3) — 한 호출 상한과 호출 간격. 차감과 같은 트랜잭션에서 판정 · 기록한다. + if (count > zapMaxPerCall) fail(ERR.questAbnormal, '한 번에 보낼 수 있는 Zap 횟수를 넘었습니다.'); + const limitRef = getFirestore().doc(`users/${uid}/rateLimits/zap`); return spendPop({ uid, popType: popType(req.data), @@ -83,6 +86,13 @@ export const fnPopSpend = onCall(async (req) => { ...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}), // 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다. count, + precheck: async (tx) => { + const last = (await tx.get(limitRef)).get('lastAt') as Timestamp | undefined; + if (last && Date.now() - last.toMillis() < zapMinIntervalMs) { + fail(ERR.questAbnormal, '너무 빠르게 Zap 했습니다. 잠시 뒤 다시 시도해 주세요.'); + } + }, + alsoWrite: (tx) => tx.set(limitRef, { lastAt: Timestamp.now() }), }); }); diff --git a/app/firebase/functions/test/eclipse.test.ts b/app/firebase/functions/test/eclipse.test.ts index f3719f7..b140fa9 100644 --- a/app/firebase/functions/test/eclipse.test.ts +++ b/app/firebase/functions/test/eclipse.test.ts @@ -190,7 +190,8 @@ describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)', expect(summary.get('defense')).toBe(60); expect(summary.get('defenseCount')).toBe(20); expect(await logCount()).toBe(20); - expect(await tickerCount()).toBe(20); + // 티커는 사람당 합류 1회 — 개별 Shake 를 방송하지 않는다(FX §5.1). + expect(await tickerCount()).toBe(2); }); }); @@ -263,8 +264,8 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () = }); expect(third.accepted).toBe(0); expect((await shardSum()).defense).toBe(60); - // 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다. - expect(await tickerCount()).toBe(2); + // 티커는 합류 1회뿐이다 — 묶음마다 · 빈 참여마다 흐르지 않는다(FX §1.1 · §5.1). + expect(await tickerCount()).toBe(1); }); it('속도 제한보다 빠른 연속 호출을 끊는다', async () => { @@ -301,7 +302,7 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () = }); describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => { - it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => { + it('합류할 때 종류·닉네임·우선순위·이동처를 남기고 Shake 횟수는 싣지 않는다', async () => { await seed(); await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7, @@ -314,7 +315,7 @@ describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => { // 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다. expect(event.get('kind')).toBe('eclipse_defense'); expect(event.get('nickname')).toBe('아미보이'); - expect(event.get('defense')).toBe(7); + expect(event.get('defense')).toBeUndefined(); expect(event.get('eclipseId')).toBe(ECLIPSE); expect(typeof event.get('priority')).toBe('number'); expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`); diff --git a/app/firebase/functions/test/growth.test.ts b/app/firebase/functions/test/growth.test.ts index b0f3f00..780d9f2 100644 --- a/app/firebase/functions/test/growth.test.ts +++ b/app/firebase/functions/test/growth.test.ts @@ -160,7 +160,7 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 const { fnPopSpend } = await import('../src/ledger/pop.js'); const run = (data: Record) => fnPopSpend.run( { data, auth: { uid, token: {} } } as Parameters[0]); - await db.doc('config/policy').set({ zapPop: 10 }, { merge: true }); + await db.doc('config/policy').set({ zapPop: 10, zapMinIntervalMs: 0 }, { merge: true }); await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'zap:seed' }); await expect(run({ popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'zap:h1' })) @@ -192,3 +192,22 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`)); await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`)); }); + +it('연속 ZAP Rate Limit — 한 호출 상한 · 호출 간격(운영값, FX §3)', async () => { + const db = getFirestore(); + const { fnPopSpend } = await import('../src/ledger/pop.js'); + const run = (data: Record) => fnPopSpend.run( + { data, auth: { uid, token: {} } } as Parameters[0]); + await db.doc('config/policy').set({ zapPop: 10, zapMaxPerCall: 3, zapMinIntervalMs: 60_000 }, { merge: true }); + await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'rl:seed' }); + await expect(run({ popType: 'activity', amount: 40, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:4' })) + .rejects.toMatchObject({ details: { code: 'E-QST-02' } }); + await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' }); + // 같은 요청의 재시도는 간격에 걸리지 않는다(멱등). + await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' }); + await expect(run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:2' })) + .rejects.toMatchObject({ details: { code: 'E-QST-02' } }); + expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(70); + await db.doc('config/policy').set({ zapMaxPerCall: 50, zapMinIntervalMs: 400 }, { merge: true }); + await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`)); +}); diff --git a/app/firebase/functions/test/weekly.test.ts b/app/firebase/functions/test/weekly.test.ts new file mode 100644 index 0000000..dbef5db --- /dev/null +++ b/app/firebase/functions/test/weekly.test.ts @@ -0,0 +1,44 @@ +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { getFirestore } from 'firebase-admin/firestore'; +import { bootAdmin, shutdownAdmin } from './support.js'; +import { snapshotWeeklyRanking, weekKeyOf } from '../src/fandom/weekly.js'; + +/** 별첨 B 「Ranking Snapshot — 주 1회 영구 Archive」. */ +const ids = ['test-week-a', 'test-week-b', 'test-week-c', 'test-week-out']; +beforeAll(async () => { + bootAdmin(); + const db = getFirestore(); + await db.doc('fandoms/test-week-a').set({ name: 'A', fpiScore: 300, rankingEligible: true }); + await db.doc('fandoms/test-week-b').set({ name: 'B', fpiScore: 300, rankingEligible: true }); + await db.doc('fandoms/test-week-c').set({ name: 'C', fpiScore: 100, rankingEligible: true }); + await db.doc('fandoms/test-week-out').set({ name: 'X', fpiScore: 999, rankingEligible: false }); +}); +afterAll(async () => { + const db = getFirestore(); + for (const id of ids) await db.recursiveDelete(db.doc(`fandoms/${id}`)); + await db.recursiveDelete(db.collection('rankingSnapshots')); + await shutdownAdmin(); +}); + +it('월요일 00:10 KST 에 돌면 방금 끝난 주를 가리킨다', () => { + // 2026-10-05(월) 00:10 KST → 9/28~10/4 = 2026-W40. + expect(weekKeyOf(new Date('2026-10-05T00:10:00+09:00'))).toBe('2026-W40'); + expect(weekKeyOf(new Date('2027-01-04T00:10:00+09:00'))).toBe('2026-W53'); +}); + +it('모집단 행성의 공동 순위를 한 번만 영구 보관한다', async () => { + const db = getFirestore(); + const at = new Date('2026-10-05T00:10:00+09:00'); + expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: true }); + const snap = (await db.doc('rankingSnapshots/2026-W40').get()).data()!; + const ours = (snap.planets as Array<{ fandomId: string; rank: number }>) + .filter((p) => ids.includes(p.fandomId)).map((p) => [p.fandomId, p.rank]); + expect(ours.map(([id]) => id)).toEqual(['test-week-a', 'test-week-b', 'test-week-c']); + // 같은 점수는 같은 순위(1·1·3) — 다른 테스트 행성이 섞여도 상대 순서는 같다. + expect(ours[0]![1]).toBe(ours[1]![1]); + // 다시 돌아도 덮지 않는다. + await db.doc('fandoms/test-week-c').update({ fpiScore: 9_999 }); + expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: false }); + const again = (await db.doc('rankingSnapshots/2026-W40').get()).data()!; + expect(again.planets).toEqual(snap.planets); +}); diff --git a/app/lib/core/data/planet_visual_state.dart b/app/lib/core/data/planet_visual_state.dart index 3165e8b..db19017 100644 --- a/app/lib/core/data/planet_visual_state.dart +++ b/app/lib/core/data/planet_visual_state.dart @@ -11,9 +11,29 @@ class PlanetVisualState { this.heroNicknames = const [], this.heroClaimCount, this.colorKey, + this.evolutionImminent = false, }); final int? stage; + /// 다음 Stage 진화 5% 전 — Figma `2024:20758` 「다음 단계 진화 수치 도달 5%전 : 행성에 표면 아우라 발생」. + /// 서버가 적은 레벨업 진행도(`levelPop` · `sfpi`)와 다음 Stage 진입 누계(`nextStagePop` · `nextStageSfpi`)로 + /// 판정한다. 두 조건 동시 충족이 진화이므로 **낮은 쪽**이 95% 이상일 때다. + final bool evolutionImminent; + + static bool imminentFrom(Map data) { + double? ratio(String have, String need) { + final h = data[have]; + final n = data[need]; + return h is num && n is num && n > 0 ? h / n : null; + } + + final pop = ratio('levelPop', 'nextStagePop'); + final sfpi = ratio('sfpi', 'nextStageSfpi'); + if (pop == null || sfpi == null) return false; + final low = pop < sfpi ? pop : sfpi; + return low >= 0.95 && low < 1; + } + /// 생성 때 고른 행성 컬러 키(C-50 · `fn-planet-create`). 성운이면 null 이다. final String? colorKey; final int? level; @@ -42,6 +62,7 @@ class PlanetVisualState { int n when n >= 0 => n, _ => null, }, + evolutionImminent: imminentFrom(data), ); } diff --git a/app/lib/core/data/repositories.dart b/app/lib/core/data/repositories.dart index 5567ed5..55b2f6e 100644 --- a/app/lib/core/data/repositories.dart +++ b/app/lib/core/data/repositories.dart @@ -165,6 +165,7 @@ class Repositories { colorKey: current.colorKey, isHibernating: current.isHibernating, level: current.level, + evolutionImminent: current.evolutionImminent, isEclipsed: isEclipsed, isHeroTime: isHeroTime, heroEventIds: heroEventIds, diff --git a/app/lib/core/ui/live_planet.dart b/app/lib/core/ui/live_planet.dart index 1037656..822c7e8 100644 --- a/app/lib/core/ui/live_planet.dart +++ b/app/lib/core/ui/live_planet.dart @@ -42,6 +42,7 @@ class LivePlanet25D extends StatelessWidget { tint: planetColorOf(state?.colorKey), stage: state == null ? stage : state.stage, level: state?.level, + evolutionImminent: state?.evolutionImminent ?? false, isEclipsed: state?.isEclipsed ?? false, isHibernating: state?.isHibernating ?? false, isHeroTime: state?.isHeroTime ?? false, diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart index b91c6d0..022e534 100644 --- a/app/lib/core/ui/planet_25d.dart +++ b/app/lib/core/ui/planet_25d.dart @@ -70,6 +70,7 @@ class Planet25D extends StatefulWidget { this.tint, this.stage, this.level, + this.evolutionImminent = false, this.isEclipsed = false, this.isHibernating = false, this.isHeroTime = false, @@ -79,6 +80,9 @@ class Planet25D extends StatefulWidget { this.animate = true, }); + /// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]). + final bool evolutionImminent; + /// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`). final VoidCallback? onOpenHistory; final String image; @@ -281,7 +285,8 @@ class _Planet25DState extends State ), ), ), - if (stage == 6) + // Stage6 지속 아우라 · 진화 5% 전 표면 아우라(Figma `2024:20758`) — 같은 에셋 레이어를 쓴다. + if (stage == 6 || widget.evolutionImminent) Positioned.fill( child: IgnorePointer( child: CustomPaint( diff --git a/app/lib/features/eclipse/widgets/live_proof.dart b/app/lib/features/eclipse/widgets/live_proof.dart index 373e2a4..5de8a47 100644 --- a/app/lib/features/eclipse/widgets/live_proof.dart +++ b/app/lib/features/eclipse/widgets/live_proof.dart @@ -1,4 +1,3 @@ -import '../../home/home_screen.dart' show formatPop; import '../eclipse_data.dart'; import '../../../core/i18n/app_locale.dart'; @@ -6,8 +5,8 @@ import '../../../core/i18n/app_locale.dart'; /// /// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 — /// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*. -/// 이클립스 방어 참여는 같은 형식에 **참여량**을 싣는다. POP 이 아니다 — 방어는 -/// POP 을 소모하지 않는 공동 Shake 참여다(설계서 v1.1 §9-#5). +/// 이클립스 방어는 **합류 1회**만 싣고 Shake 횟수는 싣지 않는다 — UX·FX v1.5 §5.1 「다른 팬의 행동은 +/// 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」. 집약 수는 방어 화면의 유효 참여자 표시다. /// /// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을 /// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다. @@ -16,7 +15,7 @@ import '../../../core/i18n/app_locale.dart'; /// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다. String liveEventMessage(LiveEvent event) => switch (event.kind) { LiveEventKind.eclipseDefense => - appL10n.liveEclipseDefense(event.nickname, formatPop(event.defense)), + appL10n.liveEclipseDefense(event.nickname), LiveEventKind.artistHotTime => appL10n.liveHotTime(event.nickname), LiveEventKind.heroGift => diff --git a/app/lib/features/galaxy/galaxy_data.dart b/app/lib/features/galaxy/galaxy_data.dart index b260a47..254366f 100644 --- a/app/lib/features/galaxy/galaxy_data.dart +++ b/app/lib/features/galaxy/galaxy_data.dart @@ -85,10 +85,21 @@ class GalaxySnapshot { this.event, this.joined = true, this.memberships = const [], + this.joinedIds = const {}, }); final List planets; + /// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 — + /// 갤럭시에서 고른 행성이 여기에 있으면 그 행성에 Zap 한다. + final Set joinedIds; + + /// Zap 이 향할 행성 — 고른 행성이 가입 팬덤이면 그 행성, 아니면 대표 행성(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」). + PlanetNode? zapTargetFor(PlanetNode? selected) => + selected != null && (selected.isMine || joinedIds.contains(selected.id)) + ? selected + : mine; + /// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」). /// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다. final List memberships; @@ -138,6 +149,7 @@ class GalaxySnapshot { event: event, joined: joined, memberships: memberships, + joinedIds: joinedIds, ); } } @@ -181,6 +193,9 @@ abstract interface class GalaxyRepository { /// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면 /// E-FDM-04 로 거부된다. Future setPrimary(String fandomId); + + /// 행성 이름 검색 — Figma 「Galaxy_검색」(`353:217832`). 맵에 올라온 행성만이 아니라 **전체** 행성에서 찾는다. + Future> search(String query); } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** @@ -192,6 +207,15 @@ class SampleGalaxyRepository implements GalaxyRepository { @override Future setPrimary(String fandomId) async {} + @override + Future> search(String query) async { + final q = query.trim().toLowerCase(); + return [ + for (final p in (await load()).planets) + if (q.isNotEmpty && p.name.toLowerCase().contains(q)) p, + ]; + } + /// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다. @override Future zap({ diff --git a/app/lib/features/galaxy/galaxy_repository_firestore.dart b/app/lib/features/galaxy/galaxy_repository_firestore.dart index 917fbfe..df99376 100644 --- a/app/lib/features/galaxy/galaxy_repository_firestore.dart +++ b/app/lib/features/galaxy/galaxy_repository_firestore.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart' - show QueryDocumentSnapshot; + show FieldPath, QueryDocumentSnapshot; import '../../core/data/backend.dart'; import '../planet/planet_data.dart' show rankWithTies; @@ -102,9 +102,42 @@ class FirestoreGalaxyRepository implements GalaxyRepository { // Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙). joined: myFandomId.isNotEmpty, memberships: [for (final id in membershipIds) ?byId[id]], + joinedIds: { + ...?(user['fandomIds'] as List?)?.whereType(), + if (myFandomId.isNotEmpty) myFandomId, + }, ); } + /// 전체 행성 이름 접두 검색 — 이름 색인 `fandomNames/{소문자 이름}`(서버가 성운 생성 때 쓴다). + /// 맵 상위 [visibleCount] 개 안에서만 거르던 것을 고쳤다(Figma `353:217832` 「행성 검색으로 대상 행성 탐색」). + /// ponytail: 접두 일치만 — 중간 글자 일치가 필요하면 검색 색인(n-gram)을 서버에 둔다. + @override + Future> search(String query) async { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return const []; + final uid = _backend.requireUid; + final db = _backend.db; + final user = (await db.doc('users/$uid').get()).data() ?? const {}; + final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId')); + final names = await db + .collection('fandomNames') + .where(FieldPath.documentId, isGreaterThanOrEqualTo: q) + .where(FieldPath.documentId, isLessThan: '$q\uf8ff') + .limit(20) + .get(); + final ids = { + for (final d in names.docs) + if (d.data()['fandomId'] case final String id when id.isNotEmpty) id, + }; + final docs = await Future.wait([for (final id in ids) db.doc('fandoms/$id').get()]); + return [ + for (final doc in docs) + if (doc.data() case final data?) + _node(doc.id, data, 0, myFandomId, ranked: false), + ]; + } + PlanetNode _node( String id, Map data, diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart index 3e5dc92..cc3f291 100644 --- a/app/lib/features/galaxy/galaxy_screen.dart +++ b/app/lib/features/galaxy/galaxy_screen.dart @@ -601,7 +601,7 @@ class _Map extends StatelessWidget { ), ), - if (data.mine case final mine?) + if (data.zapTargetFor(selected) case final mine?) Positioned.fill( child: IgnorePointer( child: AnimatedBuilder( @@ -610,6 +610,7 @@ class _Map extends StatelessWidget { touchSerial: touchSerial, pending: pending > 0 || sending, result: zapResult, + fandomName: mine.name, source: Offset( viewport.width / 2, viewport.height - @@ -653,8 +654,8 @@ class _Map extends StatelessWidget { // 하단의 GALAXY 라벨·돋보기(40) 위에 놓는다. bottom: AppSpace.s4 + 40 + AppSpace.s3, child: LiveStreamBuilder( - key: ValueKey(('zap-mode', data.mine?.id)), - create: () => watchPlanet(data.mine?.id ?? ''), + key: ValueKey(('zap-mode', data.zapTargetFor(selected)?.id)), + create: () => watchPlanet(data.zapTargetFor(selected)?.id ?? ''), builder: (context, state) { final hero = state?.isHeroTime == true && @@ -679,7 +680,8 @@ class _Map extends StatelessWidget { ? context.l10n.zapNoPlanet : null, onZap: () { - if (data.mine case final mine?) { + // 고른 행성이 가입 팬덤이면 그 행성에, 아니면 대표 행성에(10/3 #10). + if (data.zapTargetFor(selected) case final mine?) { if (hero) { showModalBottomSheet( context: context, diff --git a/app/lib/features/galaxy/galaxy_search_screen.dart b/app/lib/features/galaxy/galaxy_search_screen.dart index b08f288..423904b 100644 --- a/app/lib/features/galaxy/galaxy_search_screen.dart +++ b/app/lib/features/galaxy/galaxy_search_screen.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/play_banner.dart'; @@ -32,20 +35,62 @@ class _GalaxySearchScreenState extends State { late Future _snapshot; final _controller = TextEditingController(); - /// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다. + /// 최근 검색어 — 기기에 남긴다(재진입해도 유지). 명세 `523:18429` 「최신순 · 중복 제거」. final List _recent = []; + static const _recentKey = 'galaxyRecentSearches'; String _query = ''; + /// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다. + Future>? _hits; + Timer? _debounce; + @override void initState() { super.initState(); _snapshot = widget.repository.load(); - _controller.addListener(() => setState(() => _query = _controller.text)); + _controller.addListener(_onChanged); + _loadRecent(); + } + + Future _loadRecent() async { + try { + final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey); + if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved)); + } catch (_) { + // 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다. + } + } + + Future _saveRecent() async { + try { + await (await SharedPreferences.getInstance()).setStringList(_recentKey, _recent); + } catch (_) {} + } + + void _onChanged() { + if (_controller.text == _query) return; + setState(() => _query = _controller.text); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + if (!mounted) return; + final q = _query.trim(); + setState(() { + _hits = q.isEmpty ? null : _search(q); + }); + }); + } + + /// 서버 결과에 맵 스냅샷의 순위를 입힌다 — 맵 상위에 있는 행성은 같은 순위로 보인다. + Future> _search(String q) async { + final hits = await widget.repository.search(q); + final known = {for (final p in (await _snapshot).planets) p.id: p}; + return [for (final h in hits) known[h.id] ?? h]; } @override void dispose() { + _debounce?.cancel(); _controller.dispose(); super.dispose(); } @@ -59,6 +104,7 @@ class _GalaxySearchScreenState extends State { ..insert(0, term); if (_recent.length > 10) _recent.removeLast(); }); + _saveRecent(); } @override @@ -135,34 +181,41 @@ class _GalaxySearchScreenState extends State { if (snap.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } - final planets = snap.data?.planets ?? const []; if (_query.trim().isEmpty) { return _Recent( terms: _recent, - onClear: () => setState(_recent.clear), + onClear: () { + setState(_recent.clear); + _saveRecent(); + }, onPick: (t) { _controller.text = t; _submit(t); }, ); } - final hits = planets - .where( - (p) => - p.name.toLowerCase().contains(_query.trim().toLowerCase()), - ) - .toList(); - if (hits.isEmpty) return const _NoHit(); - return ListView.separated( - padding: const EdgeInsets.all(AppSpace.s4), - itemCount: hits.length, - separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2), - itemBuilder: (context, i) => _Hit( - planet: hits[i], - onTap: widget.onOpen == null - ? null - : () => widget.onOpen!(hits[i]), - ), + return FutureBuilder>( + future: _hits, + builder: (context, result) { + if (_hits == null || + result.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final hits = result.data ?? const []; + if (hits.isEmpty) return const _NoHit(); + return ListView.separated( + padding: const EdgeInsets.all(AppSpace.s4), + itemCount: hits.length, + separatorBuilder: (_, _) => + const SizedBox(height: AppSpace.s2), + itemBuilder: (context, i) => _Hit( + planet: hits[i], + onTap: widget.onOpen == null + ? null + : () => widget.onOpen!(hits[i]), + ), + ); + }, ); }, ), diff --git a/app/lib/features/galaxy/widgets/zap_feedback.dart b/app/lib/features/galaxy/widgets/zap_feedback.dart index de99f97..c84d349 100644 --- a/app/lib/features/galaxy/widgets/zap_feedback.dart +++ b/app/lib/features/galaxy/widgets/zap_feedback.dart @@ -18,8 +18,12 @@ class ZapFeedback extends StatefulWidget { required this.source, required this.target, required this.radius, + this.fandomName, }); + /// 기여가 반영된 행성 이름 — 9/21 회신 6절 「N POP 이 [팬덤] 성장에 반영되었습니다」. + final String? fandomName; + final int touchSerial; final bool pending; final ZapResult? result; @@ -112,6 +116,11 @@ class _ZapFeedbackState extends State ? context.l10n.zapApplying : result?.contributedPop == null ? context.l10n.zapDone + : (widget.fandomName ?? '').isNotEmpty + ? context.l10n.zapReflected( + formatPop(result!.contributedPop!), + widget.fandomName!, + ) : context.l10n.zapDonePop( formatPop(result!.contributedPop!), ), diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index 591480e..5c2ce3c 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -523,6 +523,26 @@ class _HomeBody extends StatelessWidget { ), ), ), + // Figma `2024:20758` — 행성 터치는 흔들림 1번 뒤 커뮤니티(팬덤 라운지)로. + onTapPlanet: + planetRepository == null || + data.fandomId.isEmpty + ? null + : () => Navigator.of( + context, + rootNavigator: true, + ).push( + MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + repository: planetRepository!( + data.fandomId, + ), + watchPlanet: watchPlanet, + eclipse: eclipse, + initialTab: 1, + ), + ), + ), onOpenHistory: planetRepository == null ? null : () => Navigator.of(context).push( diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index 72e5b55..2fe6624 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -8,6 +8,7 @@ import '../../../core/ui/hero_time_status.dart'; import 'growth_conditions.dart'; import 'dart:math' as math; +import 'package:flutter/services.dart' show HapticFeedback; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; @@ -38,6 +39,7 @@ class HomeHero extends StatefulWidget { this.onHeroParticipate, this.onOpenHistory, this.onOpenPlanet, + this.onTapPlanet, this.zap, this.zapData, this.ticker, @@ -61,6 +63,9 @@ class HomeHero extends StatefulWidget { /// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다. final VoidCallback? onOpenPlanet; + + /// 행성 본체 터치 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번, 커뮤니티 이동」. + final VoidCallback? onTapPlanet; final String fandomId; final PlanetWatch watchPlanet; final VoidCallback? onHeroParticipate; @@ -207,7 +212,9 @@ class _HomeHeroState extends State { clipBehavior: Clip.none, children: [ Positioned.fill( - child: LivePlanet25D( + child: _TapShake( + onTap: widget.onTapPlanet, + child: LivePlanet25D( onOpenHistory: widget.onOpenHistory, key: _planetKey, fandomId: fandomId, @@ -221,6 +228,7 @@ class _HomeHeroState extends State { ), glow: AppColors.neonViolet, ), + ), ), ], ), @@ -265,6 +273,7 @@ class _HomeHeroState extends State { Positioned.fill( child: IgnorePointer( child: ZapFeedback( + fandomName: widget.growth.fandomName, touchSerial: zap.touchSerial, pending: zap.pending > 0 || zap.sending, result: zap.result, @@ -1022,3 +1031,49 @@ class HomeZap extends StatelessWidget { }, ); } + + +/// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic. +/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다. +class _TapShake extends StatefulWidget { + const _TapShake({required this.child, this.onTap}); + + final Widget child; + final VoidCallback? onTap; + + @override + State<_TapShake> createState() => _TapShakeState(); +} + +class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixin { + late final _shake = AnimationController(vsync: this, duration: const Duration(milliseconds: 320)); + + @override + void dispose() { + _shake.dispose(); + super.dispose(); + } + + Future _tap() async { + final onTap = widget.onTap; + if (onTap == null || _shake.isAnimating) return; + HapticFeedback.lightImpact(); + if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0); + if (mounted) onTap(); + } + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onTap == null ? null : _tap, + child: AnimatedBuilder( + animation: _shake, + // 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. + builder: (context, child) => Transform.translate( + offset: Offset(math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value), 0), + child: child, + ), + child: widget.child, + ), + ); +} diff --git a/app/lib/features/planet/fpi_ranking_screen.dart b/app/lib/features/planet/fpi_ranking_screen.dart index 8951f21..f62b3a6 100644 --- a/app/lib/features/planet/fpi_ranking_screen.dart +++ b/app/lib/features/planet/fpi_ranking_screen.dart @@ -192,6 +192,20 @@ class _RankingBody extends StatelessWidget { return ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ + // 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다. + if (data.refreshMinutes case final minutes?) ...[ + Text( + [ + if (data.updatedAt case final at?) + context.l10n.planetLastUpdated( + '${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}', + ), + context.l10n.rankRefreshEvery('$minutes'), + ].join(' · '), + style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500), + ), + const SizedBox(height: AppSpace.s3), + ], if (data.mine case final me?) ...[ Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)), const SizedBox(height: AppSpace.s4), diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index d3644c5..7424daf 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -123,10 +123,29 @@ String countryName(String code) { /// 랭킹 한 판. class FpiRanking { - const FpiRanking({required this.rows, this.mine, this.mockNote}); + const FpiRanking({ + required this.rows, + this.mine, + this.mockNote, + this.updatedAt, + this.refreshMinutes, + }); final List rows; + /// 마지막 갱신 시각 · 갱신 주기(분) — 별첨 1 ② 예외처리 「갱신 주기·마지막 갱신 시각 화면 안내」. + /// 행성 랭킹은 1분 집계(`fpiUpdatedAt`), 사용자 · 국가 랭킹은 5분 집계다. + final DateTime? updatedAt; + final int? refreshMinutes; + + FpiRanking withUpdate(DateTime? at, int minutes) => FpiRanking( + rows: rows, + mine: mine, + mockNote: mockNote, + updatedAt: at, + refreshMinutes: minutes, + ); + /// 내 줄(행성 랭킹이면 내 행성, 사용자 랭킹이면 나). 목록 밖에 따로 띄운다 — /// Figma `546:14628` 「소속 행성 지표 랭킹 영역」 · `546:14668` 「나의 랭킹 영역」. final FpiRankRow? mine; @@ -251,8 +270,14 @@ class PlanetDetail { this.fpiUpdatedAt, this.joined = true, this.growthPop = 0, + this.nebulaTargetPop = 1000, + this.nebulaTargetMembers = 10, }); + /// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`). + final int nebulaTargetPop; + final int nebulaTargetMembers; + /// 성운 누적 POP(`fandoms.growthPop`) — 성운 FPI 탭 「성운 성장률」의 분자. final int growthPop; diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index 1083de5..047245e 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -14,7 +14,6 @@ import '../../core/ui/app_icons.dart'; import '../eclipse/eclipse_data.dart'; import '../eclipse/widgets/eclipse_banner.dart'; import '../home/home_screen.dart' show formatPop; -import '../home/home_data.dart' show defaultNebulaTargets; import 'lounge_post_screens.dart'; import 'moderation.dart'; import 'planet_data.dart'; @@ -41,8 +40,12 @@ class PlanetDetailScreen extends StatefulWidget { this.watchPlanet = noPlanetWatch, required this.repository, required this.eclipse, + this.initialTab = 0, }); + /// 처음 열 탭 — 0 FPI · 1 팬덤 라운지. HOME 행성 터치는 커뮤니티(라운지)로 연다(Figma `2024:20758`). + final int initialTab; + /// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §P-08). final bool nebula; final PlanetRepository repository; @@ -129,6 +132,7 @@ class _PlanetDetailScreenState extends State { final top = MediaQuery.paddingOf(context).top; return DefaultTabController( length: 2, + initialIndex: widget.initialTab.clamp(0, 1), child: Scaffold( body: NestedScrollView( headerSliverBuilder: (context, _) => [ @@ -431,7 +435,13 @@ class _FpiTab extends StatelessWidget { padding: const EdgeInsets.all(16), sliver: SliverList.list( children: [ - _NebulaFpi(pop: detail.growthPop, repository: repository), + _NebulaFpi( + pop: detail.growthPop, + target: detail.nebulaTargetPop, + members: detail.members, + targetMembers: detail.nebulaTargetMembers, + repository: repository, + ), ], ), ), @@ -525,9 +535,20 @@ class _FpiTab extends StatelessWidget { /// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`. /// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다. class _NebulaFpi extends StatelessWidget { - const _NebulaFpi({required this.pop, required this.repository}); + const _NebulaFpi({ + required this.pop, + required this.target, + required this.members, + required this.targetMembers, + required this.repository, + }); final int pop; + + /// 전환 목표 — 서버 `config/policy`(B-10). 탄생은 POP · 창립 인원 **두 조건**이다. + final int target; + final int members; + final int targetMembers; final PlanetRepository repository; static TextStyle _b(double size, Color color, {double? height}) => TextStyle( @@ -550,8 +571,7 @@ class _NebulaFpi extends StatelessWidget { @override Widget build(BuildContext context) { - final target = defaultNebulaTargets.pop; - final rate = pop * 100 ~/ target; + final rate = target <= 0 ? 0 : (pop * 100 ~/ target).clamp(0, 100); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -593,7 +613,7 @@ class _NebulaFpi extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(AppRadius.full), child: LinearProgressIndicator( - value: pop / target, + value: target <= 0 ? 0 : (pop / target).clamp(0.0, 1.0), minHeight: 10, backgroundColor: Colors.black.withValues(alpha: 0.3), color: AppColors.cosmicTeal, @@ -610,6 +630,12 @@ class _NebulaFpi extends StatelessWidget { ), ], ), + const SizedBox(height: 8), + // 인원 조건도 함께 — 탄생은 POP · 창립 인원 동시 충족(B-10). + Text( + '${context.l10n.growthFounders} ${formatPop(members)} / ${formatPop(targetMembers)}', + style: _b(12, const Color(0xFFA3A2A0)), + ), ], ), ), diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index a6ced16..7b740e2 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -5,6 +5,7 @@ import '../../core/data/backend.dart'; import '../campaign/campaign_data.dart' show ContributorRank; import '../my/my_data.dart' show PickedPhoto, PhotoRejected; import '../my/my_repository_firestore.dart'; +import '../home/home_data.dart' show nebulaTargetsFrom; import 'moderation.dart' show reportReasonCodes; import 'planet_data.dart'; import '../../core/i18n/app_locale.dart'; @@ -165,6 +166,8 @@ class FirestorePlanetRepository implements PlanetRepository { .limit(1) .get(); final memberF = db.doc('fandoms/$fandomId/members/$uid').get(); + // 성운 전환 목표는 서버 판정과 같은 운영값을 읽는다(앱 상수 아님 · B-10). + final policyF = db.doc('config/policy').get(); _cursor = null; final postsF = _page(pageSize); @@ -173,6 +176,7 @@ class FirestorePlanetRepository implements PlanetRepository { final memberSnap = await memberF; final member = memberSnap.data() ?? const {}; final posts = await postsF; + final targets = nebulaTargetsFrom((await policyF).data()); final axes = _map(fandom, 'fpiAxes'); final ranks = _map(fandom, 'fpiRanks'); @@ -212,6 +216,8 @@ class FirestorePlanetRepository implements PlanetRepository { isNebula: stage == null ? nebula : stage == 0, members: fandom.intOr('memberCount'), growthPop: fandom.intOr('growthPop'), + nebulaTargetPop: targets.pop, + nebulaTargetMembers: targets.members, scores: [ for (final metric in FpiMetric.values) FpiScore( @@ -236,10 +242,22 @@ class FirestorePlanetRepository implements PlanetRepository { ); } + /// 행성 랭킹 — 서버 1분 집계(`fnFpiAggregate`)가 적은 `fpiUpdatedAt` 중 가장 늦은 값이 마지막 갱신이다. @override Future planetRanking({ required FpiMetric metric, int limit = 30, + }) async { + final ranking = await _planetRanking(metric: metric, limit: limit); + final me = (await _backend.db.doc('fandoms/$fandomId').get()).data(); + return ranking.withUpdate(_time(me?['fpiUpdatedAt']), 1); + } + + static DateTime? _time(Object? v) => v is Timestamp ? v.toDate() : null; + + Future _planetRanking({ + required FpiMetric metric, + required int limit, }) async { final axis = _axisKey(metric); // 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」. @@ -310,11 +328,22 @@ class FirestorePlanetRepository implements PlanetRepository { ); } + /// 사용자 랭킹 — 서버 5분 집계(`fnRankingCountryAggregate`)가 팬덤 문서에 적는 `memberRankedAt`. @override Future userRanking({ required FpiMetric metric, required String fandomId, int limit = 30, + }) async { + final ranking = await _userRanking(metric: metric, fandomId: fandomId, limit: limit); + final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data(); + return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5); + } + + Future _userRanking({ + required FpiMetric metric, + required String fandomId, + required int limit, }) async { final axis = _axisKey(metric); final uid = _backend.requireUid; @@ -470,6 +499,8 @@ class FirestorePlanetRepository implements PlanetRepository { return FpiRanking( rows: list, mine: list.where((r) => r.isMine).firstOrNull, + updatedAt: _time(doc.data()?['updatedAt']), + refreshMinutes: 5, ); } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index ad03a37..3ef912c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -201,7 +201,7 @@ "eclipseBannerDefense": "Valid participants {count} / {target}", "timeHoursMinutesLeft": "{hours}h {minutes}m left", "timeMinutesLeft": "{minutes}m left", - "liveEclipseDefense": "[LIVE] {name} joined the eclipse defense! (+{count})", + "liveEclipseDefense": "[LIVE] {name} joined the eclipse defense!", "liveHotTime": "[LIVE] {name}'s Artist Hot Time has started!", "liveHeroGift": "[LIVE] A Hero's Gift from {name} has arrived!", "liveCampaign": "[LIVE] {name} backed a campaign!", @@ -235,6 +235,7 @@ "zapDone": "Contributed", "zapDonePop": "Contributed {pop} POP", "growthFounders": "Founding members", + "zapReflected": "{pop} POP added to {fandom}'s growth", "growthNebulaGoalPassed": "The goal period has passed · Meet the conditions and the planet will be born", "growthFinalStage": "Final stage", "growthPromotionPending": "Awaiting promotion", @@ -486,6 +487,7 @@ "rankEmpty": "No rankings yet", "rankScope": "{scope} ranking", "rankLoadFailed": "Couldn't load the ranking", + "rankRefreshEvery": "Updates every {minutes} min", "rankMeSuffix": "{name} (me)", "reportDefamation": "Artist slander or defamation", "reportInappropriate": "Inappropriate content", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index b4fad2e..8b3b8fc 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -201,7 +201,7 @@ "eclipseBannerDefense": "Participantes válidos {count} / {target}", "timeHoursMinutesLeft": "Quedan {hours} h {minutes} min", "timeMinutesLeft": "Quedan {minutes} min", - "liveEclipseDefense": "[LIVE] ¡{name} se unió a la defensa del eclipse! (+{count})", + "liveEclipseDefense": "[LIVE] ¡{name} se unió a la defensa del eclipse!", "liveHotTime": "[LIVE] ¡Comenzó el Artist Hot Time de {name}!", "liveHeroGift": "[LIVE] ¡Llegó el Regalo del Héroe de {name}!", "liveCampaign": "[LIVE] ¡{name} apoyó una campaña!", @@ -235,6 +235,7 @@ "zapDone": "Contribución completada", "zapDonePop": "{pop} POP contribuidos", "growthFounders": "Miembros fundadores", + "zapReflected": "{pop} POP se sumaron al crecimiento de {fandom}", "growthNebulaGoalPassed": "El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta", "growthFinalStage": "Etapa final", "growthPromotionPending": "Ascenso pendiente", @@ -486,6 +487,7 @@ "rankEmpty": "Aún no hay ranking", "rankScope": "Ranking {scope}", "rankLoadFailed": "No se pudo cargar el ranking", + "rankRefreshEvery": "Se actualiza cada {minutes} min", "rankMeSuffix": "{name} (yo)", "reportDefamation": "Difamación o insultos al artista", "reportInappropriate": "Contenido inapropiado", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index c971922..75124b5 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -201,7 +201,7 @@ "eclipseBannerDefense": "有効参加 {count} / {target}人", "timeHoursMinutesLeft": "残り{hours}時間{minutes}分", "timeMinutesLeft": "残り{minutes}分", - "liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!(+{count})", + "liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!", "liveHotTime": "[LIVE] {name}さんのアーティストホットタイムが始まりました!", "liveHeroGift": "[LIVE] {name}さんの英雄のギフトが届きました!", "liveCampaign": "[LIVE] {name}さんがキャンペーンを支援しました!", @@ -235,6 +235,7 @@ "zapDone": "貢献完了", "zapDonePop": "{pop} POP 貢献完了", "growthFounders": "創立メンバー", + "zapReflected": "{pop} POP が {fandom} の成長に反映されました", "growthNebulaGoalPassed": "目標期間が過ぎました · 条件を満たすと惑星が誕生します", "growthFinalStage": "最終ステージ", "growthPromotionPending": "昇格確定待ち", @@ -486,6 +487,7 @@ "rankEmpty": "まだ集計されたランキングはありません", "rankScope": "{scope}ランキング", "rankLoadFailed": "ランキングを読み込めませんでした", + "rankRefreshEvery": "{minutes}分ごとに更新", "rankMeSuffix": "{name}(自分)", "reportDefamation": "アーティストへの誹謗・名誉毀損", "reportInappropriate": "不適切なコンテンツ", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index e3feb67..b44d6a5 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -422,14 +422,11 @@ } } }, - "liveEclipseDefense": "[LIVE] {name}님이 이클립스 방어에 참여했습니다! (+{count})", + "liveEclipseDefense": "[LIVE] {name}님이 이클립스 방어에 합류했습니다!", "@liveEclipseDefense": { "placeholders": { "name": { "type": "String" - }, - "count": { - "type": "String" } } }, @@ -531,6 +528,17 @@ } }, "growthFounders": "창립 멤버", + "zapReflected": "{pop} POP 이 {fandom} 성장에 반영되었습니다", + "@zapReflected": { + "placeholders": { + "pop": { + "type": "String" + }, + "fandom": { + "type": "String" + } + } + }, "growthNebulaGoalPassed": "목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요", "growthFinalStage": "최종 Stage", "growthPromotionPending": "승급 확정 대기", @@ -966,6 +974,14 @@ } }, "rankLoadFailed": "랭킹을 불러오지 못했습니다", + "rankRefreshEvery": "{minutes}분마다 갱신", + "@rankRefreshEvery": { + "placeholders": { + "minutes": { + "type": "String" + } + } + }, "rankMeSuffix": "{name} (나)", "@rankMeSuffix": { "placeholders": { diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index fa21609..84a5e69 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1313,8 +1313,8 @@ abstract class AppLocalizations { /// No description provided for @liveEclipseDefense. /// /// In ko, this message translates to: - /// **'[LIVE] {name}님이 이클립스 방어에 참여했습니다! (+{count})'** - String liveEclipseDefense(String name, String count); + /// **'[LIVE] {name}님이 이클립스 방어에 합류했습니다!'** + String liveEclipseDefense(String name); /// No description provided for @liveHotTime. /// @@ -1514,6 +1514,12 @@ abstract class AppLocalizations { /// **'창립 멤버'** String get growthFounders; + /// No description provided for @zapReflected. + /// + /// In ko, this message translates to: + /// **'{pop} POP 이 {fandom} 성장에 반영되었습니다'** + String zapReflected(String pop, String fandom); + /// No description provided for @growthNebulaGoalPassed. /// /// In ko, this message translates to: @@ -3020,6 +3026,12 @@ abstract class AppLocalizations { /// **'랭킹을 불러오지 못했습니다'** String get rankLoadFailed; + /// No description provided for @rankRefreshEvery. + /// + /// In ko, this message translates to: + /// **'{minutes}분마다 갱신'** + String rankRefreshEvery(String minutes); + /// No description provided for @rankMeSuffix. /// /// 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 18601f1..6eed588 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -728,8 +728,8 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String liveEclipseDefense(String name, String count) { - return '[LIVE] $name joined the eclipse defense! (+$count)'; + String liveEclipseDefense(String name) { + return '[LIVE] $name joined the eclipse defense!'; } @override @@ -850,6 +850,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get growthFounders => 'Founding members'; + @override + String zapReflected(String pop, String fandom) { + return '$pop POP added to $fandom\'s growth'; + } + @override String get growthNebulaGoalPassed => 'The goal period has passed · Meet the conditions and the planet will be born'; @@ -1680,6 +1685,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rankLoadFailed => 'Couldn\'t load the ranking'; + @override + String rankRefreshEvery(String minutes) { + return 'Updates every $minutes min'; + } + @override String rankMeSuffix(String name) { return '$name (me)'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 59a1002..1037f85 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -738,8 +738,8 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String liveEclipseDefense(String name, String count) { - return '[LIVE] ¡$name se unió a la defensa del eclipse! (+$count)'; + String liveEclipseDefense(String name) { + return '[LIVE] ¡$name se unió a la defensa del eclipse!'; } @override @@ -860,6 +860,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get growthFounders => 'Miembros fundadores'; + @override + String zapReflected(String pop, String fandom) { + return '$pop POP se sumaron al crecimiento de $fandom'; + } + @override String get growthNebulaGoalPassed => 'El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta'; @@ -1688,6 +1693,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get rankLoadFailed => 'No se pudo cargar el ranking'; + @override + String rankRefreshEvery(String minutes) { + return 'Se actualiza cada $minutes min'; + } + @override String rankMeSuffix(String name) { return '$name (yo)'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 8a37fbe..78b614a 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -698,8 +698,8 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String liveEclipseDefense(String name, String count) { - return '[LIVE] $nameさんがイクリプス防衛に参加しました!(+$count)'; + String liveEclipseDefense(String name) { + return '[LIVE] $nameさんがイクリプス防衛に参加しました!'; } @override @@ -817,6 +817,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get growthFounders => '創立メンバー'; + @override + String zapReflected(String pop, String fandom) { + return '$pop POP が $fandom の成長に反映されました'; + } + @override String get growthNebulaGoalPassed => '目標期間が過ぎました · 条件を満たすと惑星が誕生します'; @@ -1622,6 +1627,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get rankLoadFailed => 'ランキングを読み込めませんでした'; + @override + String rankRefreshEvery(String minutes) { + return '$minutes分ごとに更新'; + } + @override String rankMeSuffix(String name) { return '$name(自分)'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index d958fac..1b8d37e 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -698,8 +698,8 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String liveEclipseDefense(String name, String count) { - return '[LIVE] $name님이 이클립스 방어에 참여했습니다! (+$count)'; + String liveEclipseDefense(String name) { + return '[LIVE] $name님이 이클립스 방어에 합류했습니다!'; } @override @@ -817,6 +817,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get growthFounders => '창립 멤버'; + @override + String zapReflected(String pop, String fandom) { + return '$pop POP 이 $fandom 성장에 반영되었습니다'; + } + @override String get growthNebulaGoalPassed => '목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요'; @@ -1623,6 +1628,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get rankLoadFailed => '랭킹을 불러오지 못했습니다'; + @override + String rankRefreshEvery(String minutes) { + return '$minutes분마다 갱신'; + } + @override String rankMeSuffix(String name) { return '$name (나)'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 49a55cd..1359ebe 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -698,8 +698,8 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String liveEclipseDefense(String name, String count) { - return '[LIVE] $name 参与了日蚀防御!(+$count)'; + String liveEclipseDefense(String name) { + return '[LIVE] $name 加入了日蚀防御!'; } @override @@ -817,6 +817,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get growthFounders => '创始成员'; + @override + String zapReflected(String pop, String fandom) { + return '$pop POP 已计入 $fandom 的成长'; + } + @override String get growthNebulaGoalPassed => '目标期限已过 · 满足条件即可诞生行星'; @@ -1621,6 +1626,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get rankLoadFailed => '无法加载排名'; + @override + String rankRefreshEvery(String minutes) { + return '每$minutes分钟更新'; + } + @override String rankMeSuffix(String name) { return '$name(我)'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index a70484b..83c34a6 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -201,7 +201,7 @@ "eclipseBannerDefense": "有效参与 {count} / {target} 人", "timeHoursMinutesLeft": "剩余 {hours} 小时 {minutes} 分", "timeMinutesLeft": "剩余 {minutes} 分", - "liveEclipseDefense": "[LIVE] {name} 参与了日蚀防御!(+{count})", + "liveEclipseDefense": "[LIVE] {name} 加入了日蚀防御!", "liveHotTime": "[LIVE] {name} 的艺人热力时刻开始了!", "liveHeroGift": "[LIVE] {name} 的英雄礼物到了!", "liveCampaign": "[LIVE] {name} 支持了一个活动!", @@ -235,6 +235,7 @@ "zapDone": "贡献完成", "zapDonePop": "已贡献 {pop} POP", "growthFounders": "创始成员", + "zapReflected": "{pop} POP 已计入 {fandom} 的成长", "growthNebulaGoalPassed": "目标期限已过 · 满足条件即可诞生行星", "growthFinalStage": "最终阶段", "growthPromotionPending": "等待晋级确认", @@ -486,6 +487,7 @@ "rankEmpty": "暂无排名数据", "rankScope": "{scope}排名", "rankLoadFailed": "无法加载排名", + "rankRefreshEvery": "每{minutes}分钟更新", "rankMeSuffix": "{name}(我)", "reportDefamation": "诽谤艺人及损害名誉", "reportInappropriate": "不当内容", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index bb35751..9d340a9 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -1157,7 +1157,7 @@ class RemoteAppLocalizations extends AppLocalizations { String liveCampaign(String name) => _fill('liveCampaign', {'name': name}) ?? _base.liveCampaign(name); @override - String liveEclipseDefense(String name, String count) => _fill('liveEclipseDefense', {'name': name, 'count': count}) ?? _base.liveEclipseDefense(name, count); + String liveEclipseDefense(String name) => _fill('liveEclipseDefense', {'name': name}) ?? _base.liveEclipseDefense(name); @override String liveHeroGift(String name) => _fill('liveHeroGift', {'name': name}) ?? _base.liveHeroGift(name); @@ -2035,6 +2035,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get rankPlanetEmpty => _remote['rankPlanetEmpty'] ?? _base.rankPlanetEmpty; + @override + String rankRefreshEvery(String minutes) => _fill('rankRefreshEvery', {'minutes': minutes}) ?? _base.rankRefreshEvery(minutes); + @override String rankScope(String scope) => _fill('rankScope', {'scope': scope}) ?? _base.rankScope(scope); @@ -2461,6 +2464,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get zapNotEnoughPop => _remote['zapNotEnoughPop'] ?? _base.zapNotEnoughPop; + @override + String zapReflected(String pop, String fandom) => _fill('zapReflected', {'pop': pop, 'fandom': fandom}) ?? _base.zapReflected(pop, fandom); + @override String get zapRetrySame => _remote['zapRetrySame'] ?? _base.zapRetrySame; } diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index 59fd094..62ffab0 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -121,6 +121,24 @@ void main() { expect(home.growth.requiredSfpi, 16575); }); + test('갤럭시 검색은 맵 상위 12개 밖의 행성도 이름 색인으로 찾는다 (Figma 353:217832)', () async { + final db = await _seed(); + for (var i = 0; i < 15; i++) { + await db.doc('fandoms/f$i').set({'name': 'Star$i', 'stage': 1, 'fpiScore': 1000 - i}); + await db.doc('fandomNames/star$i').set({'fandomId': 'f$i'}); + } + await db.doc('fandoms/low').set({'name': 'Moonlight', 'stage': 1, 'fpiScore': 1}); + await db.doc('fandomNames/moonlight').set({'fandomId': 'low'}); + final repo = FirestoreGalaxyRepository(_backend(db)); + final map = await repo.load(); + expect(map.planets.any((p) => p.id == 'low'), isFalse); + final hits = await repo.search('MOON'); + expect(hits.single.name, 'Moonlight'); + expect((await repo.search('star1')).map((p) => p.id).toSet(), + {'f1', 'f10', 'f11', 'f12', 'f13', 'f14'}); + expect(await repo.search(' '), isEmpty); + }); + test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async { final db = await _seed(); await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'}); @@ -134,10 +152,13 @@ void main() { expect(detail.stage, 4); expect(detail.isNebula, false); await db.doc('fandoms/bts').update({'stage': 0}); + // 성운 전환 목표는 앱 상수가 아니라 서버와 같은 운영값(B-10). + await db.doc('config/policy').set({'nebulaMinPop': 2000, 'nebulaMinMembers': 12}); final nebula = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') .load(nebula: false); expect(nebula.stage, 0); expect(nebula.isNebula, true); + expect([nebula.nebulaTargetPop, nebula.nebulaTargetMembers], [2000, 12]); }); test('서버에 단계가 없으면 시작 단계로 지어내지 않는다', () async { @@ -559,6 +580,20 @@ void main() { expect(ranking.mine!.change, 1); }); + test('랭킹은 마지막 갱신 시각과 갱신 주기를 함께 준다 — 별첨 1 ② 예외처리', () async { + final db = await seedPlanet(); + final at = DateTime.utc(2026, 10, 7, 3, 4); + await db.doc('fandoms/bts').update({ + 'fpiUpdatedAt': Timestamp.fromDate(at), + 'memberRankedAt': Timestamp.fromDate(at), + }); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + final planets = await repo.planetRanking(metric: FpiMetric.bpm); + expect([planets.updatedAt, planets.refreshMinutes], [at.toLocal(), 1]); + final users = await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts'); + expect([users.updatedAt, users.refreshMinutes], [at.toLocal(), 5]); + }); + test('랭킹 모집단은 최근 7일 유효 활동 행성뿐이다 — 별첨 B', () async { final db = await seedPlanet(); final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') diff --git a/app/test/eclipse_live_test.dart b/app/test/eclipse_live_test.dart index 611c969..dbc28cc 100644 --- a/app/test/eclipse_live_test.dart +++ b/app/test/eclipse_live_test.dart @@ -247,7 +247,7 @@ void main() { await _settle(tester); expect( - find.text('[LIVE] 버니즈님이 이클립스 방어에 참여했습니다! (+30)'), + find.text('[LIVE] 버니즈님이 이클립스 방어에 합류했습니다!'), findsOneWidget, ); }); @@ -269,7 +269,7 @@ void main() { expect(find.textContaining('버니즈'), findsNothing); expect( - find.text('[LIVE] 캐럿님이 이클립스 방어에 참여했습니다! (+50)'), + find.text('[LIVE] 캐럿님이 이클립스 방어에 합류했습니다!'), findsOneWidget, ); }); @@ -304,7 +304,7 @@ void main() { // 이벤트가 오면 그 문구가 폴백을 대체한다. expect(find.textContaining('아이템 사용으로 버프가 발동'), findsNothing); expect( - find.text('[LIVE] 무민님이 이클립스 방어에 참여했습니다! (+20)'), + find.text('[LIVE] 무민님이 이클립스 방어에 합류했습니다!'), findsOneWidget, ); }); @@ -313,7 +313,7 @@ void main() { final at = DateTime(2026, 9, 14); expect( liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)), - '[LIVE] 나님이 이클립스 방어에 참여했습니다! (+1,200)', + '[LIVE] 나님이 이클립스 방어에 합류했습니다!', ); // 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다. expect( diff --git a/app/test/eclipse_stubs.dart b/app/test/eclipse_stubs.dart index b69994c..8cd70b3 100644 --- a/app/test/eclipse_stubs.dart +++ b/app/test/eclipse_stubs.dart @@ -197,6 +197,10 @@ class RecordingGalaxyRepository implements GalaxyRepository { @override Future setPrimary(String fandomId) async {} + @override + Future> search(String query) => + const SampleGalaxyRepository().search(query); + @override Future load() async => snapshot ?? await const SampleGalaxyRepository().load(); diff --git a/app/test/f2_screens_test.dart b/app/test/f2_screens_test.dart index 4c3e4fc..6adb334 100644 --- a/app/test/f2_screens_test.dart +++ b/app/test/f2_screens_test.dart @@ -67,6 +67,9 @@ class _TopOrbitRepository implements GalaxyRepository { @override Future setPrimary(String fandomId) async {} + @override + Future> search(String query) async => const []; + @override Future zap({ required String fandomId, diff --git a/app/test/galaxy_zap_fx_test.dart b/app/test/galaxy_zap_fx_test.dart index 89640d7..b57cabb 100644 --- a/app/test/galaxy_zap_fx_test.dart +++ b/app/test/galaxy_zap_fx_test.dart @@ -104,7 +104,7 @@ void main() { expect(repository.counts, [2, 2]); repository.replies.last.complete(const ZapResult(txId: 'home-confirmed', balanceAfter: 900, contributedPop: 37)); await tester.pump(); - expect(find.text('37 POP 기여 완료'), findsOneWidget); + expect(find.textContaining('37 POP 이 '), findsOneWidget); expect(tester.widget(find.byType(ZapButton)).myPop, 900); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); @@ -135,6 +135,17 @@ void main() { expect(tester.binding.transientCallbackCount, 0); }); + test('갤럭시 Zap 은 고른 행성이 가입 팬덤이면 그 행성, 아니면 대표 행성으로 간다 (10/3 #10)', () { + const mine = PlanetNode(id: 'a', name: 'A', rank: 1, fpi: 1, members: 1, online: 1, isMine: true); + const joined = PlanetNode(id: 'b', name: 'B', rank: 2, fpi: 1, members: 1, online: 1); + const other = PlanetNode(id: 'c', name: 'C', rank: 3, fpi: 1, members: 1, online: 1); + const snap = GalaxySnapshot(planets: [mine, joined, other], myPop: 0, joinedIds: {'a', 'b'}); + expect(snap.zapTargetFor(joined)?.id, 'b'); + expect(snap.zapTargetFor(other)?.id, 'a'); + expect(snap.zapTargetFor(null)?.id, 'a'); + expect(snap.rankingView.zapTargetFor(joined)?.id, 'b'); + }); + test('기여 결과는 현재 단가가 아니라 서버가 확정한 차감 lot 합계다', () async { final db = FakeFirebaseFirestore(); await db.doc('config/policy').set({'zapPop': 10}); @@ -156,14 +167,14 @@ void main() { ); repository.replies.last.complete(receipt); await tester.pump(); - expect(find.text('37 POP 기여 완료'), findsOneWidget); + expect(find.textContaining('37 POP 이 '), findsOneWidget); await tester.pump(const Duration(seconds: 2)); - expect(find.text('37 POP 기여 완료'), findsNothing); + expect(find.textContaining('37 POP 이 '), findsNothing); await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 500)); repository.replies.last.complete(receipt); await tester.pump(); - expect(find.text('37 POP 기여 완료'), findsNothing); + expect(find.textContaining('37 POP 이 '), findsNothing); }); testWidgets('ZAP 옵션은 2초 전에 열리지 않고 길게 누르면 차감하지 않는다', (tester) async { diff --git a/app/test/planet_fx_test.dart b/app/test/planet_fx_test.dart index f939c4a..96c12f5 100644 --- a/app/test/planet_fx_test.dart +++ b/app/test/planet_fx_test.dart @@ -7,6 +7,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('진화 5% 전 아우라는 두 조건 중 낮은 쪽이 95% 이상일 때 (Figma 2024:20758)', () { + bool imminent(num pop, num sfpi) => PlanetVisualState.fromDocument({ + 'levelPop': pop, 'nextStagePop': 100, 'sfpi': sfpi, 'nextStageSfpi': 100, + }).evolutionImminent; + expect(imminent(96, 99), isTrue); + expect(imminent(99, 94), isFalse); + expect(imminent(100, 100), isFalse, reason: '도달은 진화 판정의 몫'); + expect(PlanetVisualState.fromDocument({'stage': 2}).evolutionImminent, isFalse); + }); + test('컬러는 마스터(config/planetColors)가 정한다 — 추가 색은 그리고 숨긴 색은 고르지 못한다 (10/3 #6)', () { final master = planetColorsFrom({'colors': [ {'key': 'gold', 'hex': '#FFC500', 'visible': false}, diff --git a/app/test/planet_growth_status_test.dart b/app/test/planet_growth_status_test.dart index db124d8..57b9b0f 100644 --- a/app/test/planet_growth_status_test.dart +++ b/app/test/planet_growth_status_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:fancake/core/theme/app_theme.dart'; +import 'package:fancake/core/ui/live_planet.dart'; import 'package:fancake/features/home/widgets/home_hero.dart'; import 'package:fancake/features/home/widgets/growth_conditions.dart'; import 'package:fancake/features/home/widgets/planet_growth_card.dart'; @@ -7,6 +8,20 @@ import 'package:fancake/features/home/home_data.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('행성 본체를 누르면 흔들린 뒤 커뮤니티로 간다 (Figma 2024:20758)', (tester) async { + const growth = PlanetGrowth(fandomName: 'BTS', contributorName: '팬', stage: 2, + contributedPop: 0, growthPercent: null, currentPop: null, requiredPop: null); + var opened = 0; + await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), + home: Scaffold(body: HomeHero(growth: growth, fandomId: 'bts', onTapPlanet: () => opened++)))); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byType(LivePlanet25D), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 100)); + expect(opened, 0, reason: '흔들림이 끝난 뒤 이동한다'); + await tester.pump(const Duration(milliseconds: 400)); + expect(opened, 1); + }); + testWidgets('기여 기록이 없으면 두 성장 영역에 가짜 0 POP 기여를 표시하지 않는다', (tester) async { const growth = PlanetGrowth(fandomName: 'BTS', contributorName: '팬', contributedPop: 0, growthPercent: null, currentPop: null, requiredPop: null);