라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응
This commit is contained in:
@@ -182,6 +182,19 @@ service cloud.firestore {
|
|||||||
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
|
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 「규칙은 공개하지 않는 것을 제안」
|
// 근거: 정책설계서 P-06 「규칙은 공개하지 않는 것을 제안」
|
||||||
match /configPrivate/{configId} {
|
match /configPrivate/{configId} {
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export interface PolicyConfig {
|
|||||||
stepTiers: StepTier[];
|
stepTiers: StepTier[];
|
||||||
/** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */
|
/** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */
|
||||||
zapPop: number;
|
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. */
|
/** FPI 집계 창 — 설계서 §4.2 최근 7일 Rolling Window. */
|
||||||
fpiWindowDays: number;
|
fpiWindowDays: number;
|
||||||
/**
|
/**
|
||||||
@@ -179,6 +187,8 @@ export const DEFAULT_POLICY: PolicyConfig = {
|
|||||||
{ steps: 15_000, pop: 8 },
|
{ steps: 15_000, pop: 8 },
|
||||||
],
|
],
|
||||||
zapPop: 10,
|
zapPop: 10,
|
||||||
|
zapMaxPerCall: 50,
|
||||||
|
zapMinIntervalMs: 400,
|
||||||
fpiWindowDays: 7,
|
fpiWindowDays: 7,
|
||||||
// 단순 합산이 초기 정책이므로 전부 1.0 이다.
|
// 단순 합산이 초기 정책이므로 전부 1.0 이다.
|
||||||
fpiWeights: {
|
fpiWeights: {
|
||||||
|
|||||||
@@ -187,10 +187,13 @@ export async function defendEclipse(input: {
|
|||||||
qualifiedParticipants: FieldValue.increment(qualified ? 1 : 0),
|
qualifiedParticipants: FieldValue.increment(qualified ? 1 : 0),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
// 라이브 소셜 프루프는 **참여 합류 1회**만 — 개별 Shake 횟수를 방송하지 않는다(UX·FX v1.5 §1.1 · §3.1 · §5.1
|
||||||
|
// 「다른 팬의 행동은 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」). 집약 수는 화면의 유효 참여자 표시.
|
||||||
|
if (accepted > 0 && myTotal === 0) {
|
||||||
tx.create(tickerRef, {
|
tx.create(tickerRef, {
|
||||||
kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid,
|
kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid,
|
||||||
nickname: publicNameOf(user),
|
nickname: publicNameOf(user),
|
||||||
defense: accepted,
|
|
||||||
linkTo: `/eclipse/${fandomId}/${eclipseId}`,
|
linkTo: `/eclipse/${fandomId}/${eclipseId}`,
|
||||||
priority: TICKER_PRIORITY[TICKER_KIND_ECLIPSE_DEFENSE] ?? 50,
|
priority: TICKER_PRIORITY[TICKER_KIND_ECLIPSE_DEFENSE] ?? 50,
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
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];
|
for (const axis of FPI_AXES) total[axis] += patch.fpiAxes[axis];
|
||||||
totals.set(code, total);
|
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;
|
memberTotal += members.size;
|
||||||
}
|
}
|
||||||
await writer.close();
|
await writer.close();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -52,4 +52,5 @@ export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
|
|||||||
|
|
||||||
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
|
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
|
||||||
export { fnPlanetHistory } from './fandom/history.js';
|
export { fnPlanetHistory } from './fandom/history.js';
|
||||||
|
export { fnWeeklyRankingSnapshot } from './fandom/weekly.js';
|
||||||
export { fnPassportOnUserUpdate, fnPassportOnMemberWrite, fnPassportDaily } from './account/passport.js';
|
export { fnPassportOnUserUpdate, fnPassportOnMemberWrite, fnPassportDaily } from './account/passport.js';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
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 { logger } from 'firebase-functions';
|
||||||
|
|
||||||
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
|
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
|
||||||
@@ -66,11 +66,14 @@ export const fnPopSpend = onCall(async (req) => {
|
|||||||
fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.');
|
fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.');
|
||||||
}
|
}
|
||||||
const amount = positiveInt(num(req.data, 'amount'), 'amount');
|
const amount = positiveInt(num(req.data, 'amount'), 'amount');
|
||||||
const { zapPop } = await loadPolicy();
|
const { zapPop, zapMaxPerCall, zapMinIntervalMs } = await loadPolicy();
|
||||||
const count = Math.floor(amount / zapPop);
|
const count = Math.floor(amount / zapPop);
|
||||||
if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) {
|
if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) {
|
||||||
fail(ERR.popVerifyFailed, `Zap 은 ${zapPop} POP 단위로만 보낼 수 있습니다.`);
|
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({
|
return spendPop({
|
||||||
uid,
|
uid,
|
||||||
popType: popType(req.data),
|
popType: popType(req.data),
|
||||||
@@ -83,6 +86,13 @@ export const fnPopSpend = onCall(async (req) => {
|
|||||||
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
|
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
|
||||||
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
|
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
|
||||||
count,
|
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() }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)',
|
|||||||
expect(summary.get('defense')).toBe(60);
|
expect(summary.get('defense')).toBe(60);
|
||||||
expect(summary.get('defenseCount')).toBe(20);
|
expect(summary.get('defenseCount')).toBe(20);
|
||||||
expect(await logCount()).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(third.accepted).toBe(0);
|
||||||
expect((await shardSum()).defense).toBe(60);
|
expect((await shardSum()).defense).toBe(60);
|
||||||
// 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다.
|
// 티커는 합류 1회뿐이다 — 묶음마다 · 빈 참여마다 흐르지 않는다(FX §1.1 · §5.1).
|
||||||
expect(await tickerCount()).toBe(2);
|
expect(await tickerCount()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
|
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
|
||||||
@@ -301,7 +302,7 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||||
it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => {
|
it('합류할 때 종류·닉네임·우선순위·이동처를 남기고 Shake 횟수는 싣지 않는다', async () => {
|
||||||
await seed();
|
await seed();
|
||||||
await defendEclipse({
|
await defendEclipse({
|
||||||
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
|
||||||
@@ -314,7 +315,7 @@ describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
|||||||
// 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다.
|
// 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다.
|
||||||
expect(event.get('kind')).toBe('eclipse_defense');
|
expect(event.get('kind')).toBe('eclipse_defense');
|
||||||
expect(event.get('nickname')).toBe('아미보이');
|
expect(event.get('nickname')).toBe('아미보이');
|
||||||
expect(event.get('defense')).toBe(7);
|
expect(event.get('defense')).toBeUndefined();
|
||||||
expect(event.get('eclipseId')).toBe(ECLIPSE);
|
expect(event.get('eclipseId')).toBe(ECLIPSE);
|
||||||
expect(typeof event.get('priority')).toBe('number');
|
expect(typeof event.get('priority')).toBe('number');
|
||||||
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
|
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
|
|||||||
const { fnPopSpend } = await import('../src/ledger/pop.js');
|
const { fnPopSpend } = await import('../src/ledger/pop.js');
|
||||||
const run = (data: Record<string, unknown>) => fnPopSpend.run(
|
const run = (data: Record<string, unknown>) => fnPopSpend.run(
|
||||||
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
|
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[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 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' }))
|
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/${a}/fpiEvents`));
|
||||||
await db.recursiveDelete(db.collection(`fandoms/${b}/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<string, unknown>) => fnPopSpend.run(
|
||||||
|
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[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`));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -11,9 +11,29 @@ class PlanetVisualState {
|
|||||||
this.heroNicknames = const [],
|
this.heroNicknames = const [],
|
||||||
this.heroClaimCount,
|
this.heroClaimCount,
|
||||||
this.colorKey,
|
this.colorKey,
|
||||||
|
this.evolutionImminent = false,
|
||||||
});
|
});
|
||||||
final int? stage;
|
final int? stage;
|
||||||
|
|
||||||
|
/// 다음 Stage 진화 5% 전 — Figma `2024:20758` 「다음 단계 진화 수치 도달 5%전 : 행성에 표면 아우라 발생」.
|
||||||
|
/// 서버가 적은 레벨업 진행도(`levelPop` · `sfpi`)와 다음 Stage 진입 누계(`nextStagePop` · `nextStageSfpi`)로
|
||||||
|
/// 판정한다. 두 조건 동시 충족이 진화이므로 **낮은 쪽**이 95% 이상일 때다.
|
||||||
|
final bool evolutionImminent;
|
||||||
|
|
||||||
|
static bool imminentFrom(Map<String, dynamic> 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 이다.
|
/// 생성 때 고른 행성 컬러 키(C-50 · `fn-planet-create`). 성운이면 null 이다.
|
||||||
final String? colorKey;
|
final String? colorKey;
|
||||||
final int? level;
|
final int? level;
|
||||||
@@ -42,6 +62,7 @@ class PlanetVisualState {
|
|||||||
int n when n >= 0 => n,
|
int n when n >= 0 => n,
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
evolutionImminent: imminentFrom(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ class Repositories {
|
|||||||
colorKey: current.colorKey,
|
colorKey: current.colorKey,
|
||||||
isHibernating: current.isHibernating,
|
isHibernating: current.isHibernating,
|
||||||
level: current.level,
|
level: current.level,
|
||||||
|
evolutionImminent: current.evolutionImminent,
|
||||||
isEclipsed: isEclipsed,
|
isEclipsed: isEclipsed,
|
||||||
isHeroTime: isHeroTime,
|
isHeroTime: isHeroTime,
|
||||||
heroEventIds: heroEventIds,
|
heroEventIds: heroEventIds,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class LivePlanet25D extends StatelessWidget {
|
|||||||
tint: planetColorOf(state?.colorKey),
|
tint: planetColorOf(state?.colorKey),
|
||||||
stage: state == null ? stage : state.stage,
|
stage: state == null ? stage : state.stage,
|
||||||
level: state?.level,
|
level: state?.level,
|
||||||
|
evolutionImminent: state?.evolutionImminent ?? false,
|
||||||
isEclipsed: state?.isEclipsed ?? false,
|
isEclipsed: state?.isEclipsed ?? false,
|
||||||
isHibernating: state?.isHibernating ?? false,
|
isHibernating: state?.isHibernating ?? false,
|
||||||
isHeroTime: state?.isHeroTime ?? false,
|
isHeroTime: state?.isHeroTime ?? false,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class Planet25D extends StatefulWidget {
|
|||||||
this.tint,
|
this.tint,
|
||||||
this.stage,
|
this.stage,
|
||||||
this.level,
|
this.level,
|
||||||
|
this.evolutionImminent = false,
|
||||||
this.isEclipsed = false,
|
this.isEclipsed = false,
|
||||||
this.isHibernating = false,
|
this.isHibernating = false,
|
||||||
this.isHeroTime = false,
|
this.isHeroTime = false,
|
||||||
@@ -79,6 +80,9 @@ class Planet25D extends StatefulWidget {
|
|||||||
this.animate = true,
|
this.animate = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]).
|
||||||
|
final bool evolutionImminent;
|
||||||
|
|
||||||
/// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`).
|
/// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`).
|
||||||
final VoidCallback? onOpenHistory;
|
final VoidCallback? onOpenHistory;
|
||||||
final String image;
|
final String image;
|
||||||
@@ -281,7 +285,8 @@ class _Planet25DState extends State<Planet25D>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (stage == 6)
|
// Stage6 지속 아우라 · 진화 5% 전 표면 아우라(Figma `2024:20758`) — 같은 에셋 레이어를 쓴다.
|
||||||
|
if (stage == 6 || widget.evolutionImminent)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import '../../home/home_screen.dart' show formatPop;
|
|
||||||
import '../eclipse_data.dart';
|
import '../eclipse_data.dart';
|
||||||
import '../../../core/i18n/app_locale.dart';
|
import '../../../core/i18n/app_locale.dart';
|
||||||
|
|
||||||
@@ -6,8 +5,8 @@ import '../../../core/i18n/app_locale.dart';
|
|||||||
///
|
///
|
||||||
/// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 —
|
/// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 —
|
||||||
/// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*.
|
/// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*.
|
||||||
/// 이클립스 방어 참여는 같은 형식에 **참여량**을 싣는다. POP 이 아니다 — 방어는
|
/// 이클립스 방어는 **합류 1회**만 싣고 Shake 횟수는 싣지 않는다 — UX·FX v1.5 §5.1 「다른 팬의 행동은
|
||||||
/// POP 을 소모하지 않는 공동 Shake 참여다(설계서 v1.1 §9-#5).
|
/// 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」. 집약 수는 방어 화면의 유효 참여자 표시다.
|
||||||
///
|
///
|
||||||
/// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을
|
/// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을
|
||||||
/// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다.
|
/// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다.
|
||||||
@@ -16,7 +15,7 @@ import '../../../core/i18n/app_locale.dart';
|
|||||||
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
|
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
|
||||||
String liveEventMessage(LiveEvent event) => switch (event.kind) {
|
String liveEventMessage(LiveEvent event) => switch (event.kind) {
|
||||||
LiveEventKind.eclipseDefense =>
|
LiveEventKind.eclipseDefense =>
|
||||||
appL10n.liveEclipseDefense(event.nickname, formatPop(event.defense)),
|
appL10n.liveEclipseDefense(event.nickname),
|
||||||
LiveEventKind.artistHotTime =>
|
LiveEventKind.artistHotTime =>
|
||||||
appL10n.liveHotTime(event.nickname),
|
appL10n.liveHotTime(event.nickname),
|
||||||
LiveEventKind.heroGift =>
|
LiveEventKind.heroGift =>
|
||||||
|
|||||||
@@ -85,10 +85,21 @@ class GalaxySnapshot {
|
|||||||
this.event,
|
this.event,
|
||||||
this.joined = true,
|
this.joined = true,
|
||||||
this.memberships = const [],
|
this.memberships = const [],
|
||||||
|
this.joinedIds = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<PlanetNode> planets;
|
final List<PlanetNode> planets;
|
||||||
|
|
||||||
|
/// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 —
|
||||||
|
/// 갤럭시에서 고른 행성이 여기에 있으면 그 행성에 Zap 한다.
|
||||||
|
final Set<String> joinedIds;
|
||||||
|
|
||||||
|
/// Zap 이 향할 행성 — 고른 행성이 가입 팬덤이면 그 행성, 아니면 대표 행성(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」).
|
||||||
|
PlanetNode? zapTargetFor(PlanetNode? selected) =>
|
||||||
|
selected != null && (selected.isMine || joinedIds.contains(selected.id))
|
||||||
|
? selected
|
||||||
|
: mine;
|
||||||
|
|
||||||
/// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」).
|
/// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」).
|
||||||
/// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다.
|
/// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다.
|
||||||
final List<PlanetNode> memberships;
|
final List<PlanetNode> memberships;
|
||||||
@@ -138,6 +149,7 @@ class GalaxySnapshot {
|
|||||||
event: event,
|
event: event,
|
||||||
joined: joined,
|
joined: joined,
|
||||||
memberships: memberships,
|
memberships: memberships,
|
||||||
|
joinedIds: joinedIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +193,9 @@ abstract interface class GalaxyRepository {
|
|||||||
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
|
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
|
||||||
/// E-FDM-04 로 거부된다.
|
/// E-FDM-04 로 거부된다.
|
||||||
Future<void> setPrimary(String fandomId);
|
Future<void> setPrimary(String fandomId);
|
||||||
|
|
||||||
|
/// 행성 이름 검색 — Figma 「Galaxy_검색」(`353:217832`). 맵에 올라온 행성만이 아니라 **전체** 행성에서 찾는다.
|
||||||
|
Future<List<PlanetNode>> search(String query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||||
@@ -192,6 +207,15 @@ class SampleGalaxyRepository implements GalaxyRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> setPrimary(String fandomId) async {}
|
Future<void> setPrimary(String fandomId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PlanetNode>> 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 을 「모름」으로 읽는다.
|
/// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다.
|
||||||
@override
|
@override
|
||||||
Future<ZapResult> zap({
|
Future<ZapResult> zap({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart'
|
import 'package:cloud_firestore/cloud_firestore.dart'
|
||||||
show QueryDocumentSnapshot;
|
show FieldPath, QueryDocumentSnapshot;
|
||||||
|
|
||||||
import '../../core/data/backend.dart';
|
import '../../core/data/backend.dart';
|
||||||
import '../planet/planet_data.dart' show rankWithTies;
|
import '../planet/planet_data.dart' show rankWithTies;
|
||||||
@@ -102,9 +102,42 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
|||||||
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
|
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
|
||||||
joined: myFandomId.isNotEmpty,
|
joined: myFandomId.isNotEmpty,
|
||||||
memberships: [for (final id in membershipIds) ?byId[id]],
|
memberships: [for (final id in membershipIds) ?byId[id]],
|
||||||
|
joinedIds: {
|
||||||
|
...?(user['fandomIds'] as List?)?.whereType<String>(),
|
||||||
|
if (myFandomId.isNotEmpty) myFandomId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 전체 행성 이름 접두 검색 — 이름 색인 `fandomNames/{소문자 이름}`(서버가 성운 생성 때 쓴다).
|
||||||
|
/// 맵 상위 [visibleCount] 개 안에서만 거르던 것을 고쳤다(Figma `353:217832` 「행성 검색으로 대상 행성 탐색」).
|
||||||
|
/// ponytail: 접두 일치만 — 중간 글자 일치가 필요하면 검색 색인(n-gram)을 서버에 둔다.
|
||||||
|
@override
|
||||||
|
Future<List<PlanetNode>> 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 <String, dynamic>{};
|
||||||
|
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(
|
PlanetNode _node(
|
||||||
String id,
|
String id,
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
|
|||||||
@@ -601,7 +601,7 @@ class _Map extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (data.mine case final mine?)
|
if (data.zapTargetFor(selected) case final mine?)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
@@ -610,6 +610,7 @@ class _Map extends StatelessWidget {
|
|||||||
touchSerial: touchSerial,
|
touchSerial: touchSerial,
|
||||||
pending: pending > 0 || sending,
|
pending: pending > 0 || sending,
|
||||||
result: zapResult,
|
result: zapResult,
|
||||||
|
fandomName: mine.name,
|
||||||
source: Offset(
|
source: Offset(
|
||||||
viewport.width / 2,
|
viewport.width / 2,
|
||||||
viewport.height -
|
viewport.height -
|
||||||
@@ -653,8 +654,8 @@ class _Map extends StatelessWidget {
|
|||||||
// 하단의 GALAXY 라벨·돋보기(40) 위에 놓는다.
|
// 하단의 GALAXY 라벨·돋보기(40) 위에 놓는다.
|
||||||
bottom: AppSpace.s4 + 40 + AppSpace.s3,
|
bottom: AppSpace.s4 + 40 + AppSpace.s3,
|
||||||
child: LiveStreamBuilder<PlanetVisualState>(
|
child: LiveStreamBuilder<PlanetVisualState>(
|
||||||
key: ValueKey(('zap-mode', data.mine?.id)),
|
key: ValueKey(('zap-mode', data.zapTargetFor(selected)?.id)),
|
||||||
create: () => watchPlanet(data.mine?.id ?? ''),
|
create: () => watchPlanet(data.zapTargetFor(selected)?.id ?? ''),
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final hero =
|
final hero =
|
||||||
state?.isHeroTime == true &&
|
state?.isHeroTime == true &&
|
||||||
@@ -679,7 +680,8 @@ class _Map extends StatelessWidget {
|
|||||||
? context.l10n.zapNoPlanet
|
? context.l10n.zapNoPlanet
|
||||||
: null,
|
: null,
|
||||||
onZap: () {
|
onZap: () {
|
||||||
if (data.mine case final mine?) {
|
// 고른 행성이 가입 팬덤이면 그 행성에, 아니면 대표 행성에(10/3 #10).
|
||||||
|
if (data.zapTargetFor(selected) case final mine?) {
|
||||||
if (hero) {
|
if (hero) {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../core/theme/tokens.dart';
|
import '../../core/theme/tokens.dart';
|
||||||
import '../../core/ui/play_banner.dart';
|
import '../../core/ui/play_banner.dart';
|
||||||
@@ -32,20 +35,62 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
|||||||
late Future<GalaxySnapshot> _snapshot;
|
late Future<GalaxySnapshot> _snapshot;
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
/// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다.
|
/// 최근 검색어 — 기기에 남긴다(재진입해도 유지). 명세 `523:18429` 「최신순 · 중복 제거」.
|
||||||
final List<String> _recent = [];
|
final List<String> _recent = [];
|
||||||
|
static const _recentKey = 'galaxyRecentSearches';
|
||||||
|
|
||||||
String _query = '';
|
String _query = '';
|
||||||
|
|
||||||
|
/// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다.
|
||||||
|
Future<List<PlanetNode>>? _hits;
|
||||||
|
Timer? _debounce;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_snapshot = widget.repository.load();
|
_snapshot = widget.repository.load();
|
||||||
_controller.addListener(() => setState(() => _query = _controller.text));
|
_controller.addListener(_onChanged);
|
||||||
|
_loadRecent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRecent() async {
|
||||||
|
try {
|
||||||
|
final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey);
|
||||||
|
if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved));
|
||||||
|
} catch (_) {
|
||||||
|
// 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<List<PlanetNode>> _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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -59,6 +104,7 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
|||||||
..insert(0, term);
|
..insert(0, term);
|
||||||
if (_recent.length > 10) _recent.removeLast();
|
if (_recent.length > 10) _recent.removeLast();
|
||||||
});
|
});
|
||||||
|
_saveRecent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -135,28 +181,33 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
|||||||
if (snap.connectionState != ConnectionState.done) {
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
final planets = snap.data?.planets ?? const <PlanetNode>[];
|
|
||||||
if (_query.trim().isEmpty) {
|
if (_query.trim().isEmpty) {
|
||||||
return _Recent(
|
return _Recent(
|
||||||
terms: _recent,
|
terms: _recent,
|
||||||
onClear: () => setState(_recent.clear),
|
onClear: () {
|
||||||
|
setState(_recent.clear);
|
||||||
|
_saveRecent();
|
||||||
|
},
|
||||||
onPick: (t) {
|
onPick: (t) {
|
||||||
_controller.text = t;
|
_controller.text = t;
|
||||||
_submit(t);
|
_submit(t);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final hits = planets
|
return FutureBuilder<List<PlanetNode>>(
|
||||||
.where(
|
future: _hits,
|
||||||
(p) =>
|
builder: (context, result) {
|
||||||
p.name.toLowerCase().contains(_query.trim().toLowerCase()),
|
if (_hits == null ||
|
||||||
)
|
result.connectionState != ConnectionState.done) {
|
||||||
.toList();
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final hits = result.data ?? const <PlanetNode>[];
|
||||||
if (hits.isEmpty) return const _NoHit();
|
if (hits.isEmpty) return const _NoHit();
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(AppSpace.s4),
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
itemCount: hits.length,
|
itemCount: hits.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2),
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: AppSpace.s2),
|
||||||
itemBuilder: (context, i) => _Hit(
|
itemBuilder: (context, i) => _Hit(
|
||||||
planet: hits[i],
|
planet: hits[i],
|
||||||
onTap: widget.onOpen == null
|
onTap: widget.onOpen == null
|
||||||
@@ -165,6 +216,8 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ class ZapFeedback extends StatefulWidget {
|
|||||||
required this.source,
|
required this.source,
|
||||||
required this.target,
|
required this.target,
|
||||||
required this.radius,
|
required this.radius,
|
||||||
|
this.fandomName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 기여가 반영된 행성 이름 — 9/21 회신 6절 「N POP 이 [팬덤] 성장에 반영되었습니다」.
|
||||||
|
final String? fandomName;
|
||||||
|
|
||||||
final int touchSerial;
|
final int touchSerial;
|
||||||
final bool pending;
|
final bool pending;
|
||||||
final ZapResult? result;
|
final ZapResult? result;
|
||||||
@@ -112,6 +116,11 @@ class _ZapFeedbackState extends State<ZapFeedback>
|
|||||||
? context.l10n.zapApplying
|
? context.l10n.zapApplying
|
||||||
: result?.contributedPop == null
|
: result?.contributedPop == null
|
||||||
? context.l10n.zapDone
|
? context.l10n.zapDone
|
||||||
|
: (widget.fandomName ?? '').isNotEmpty
|
||||||
|
? context.l10n.zapReflected(
|
||||||
|
formatPop(result!.contributedPop!),
|
||||||
|
widget.fandomName!,
|
||||||
|
)
|
||||||
: context.l10n.zapDonePop(
|
: context.l10n.zapDonePop(
|
||||||
formatPop(result!.contributedPop!),
|
formatPop(result!.contributedPop!),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<void>(
|
||||||
|
builder: (_) => PlanetDetailScreen(
|
||||||
|
repository: planetRepository!(
|
||||||
|
data.fandomId,
|
||||||
|
),
|
||||||
|
watchPlanet: watchPlanet,
|
||||||
|
eclipse: eclipse,
|
||||||
|
initialTab: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
onOpenHistory: planetRepository == null
|
onOpenHistory: planetRepository == null
|
||||||
? null
|
? null
|
||||||
: () => Navigator.of(context).push(
|
: () => Navigator.of(context).push(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/ui/hero_time_status.dart';
|
|||||||
import 'growth_conditions.dart';
|
import 'growth_conditions.dart';
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/services.dart' show HapticFeedback;
|
||||||
import 'dart:ui' show ImageFilter;
|
import 'dart:ui' show ImageFilter;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -38,6 +39,7 @@ class HomeHero extends StatefulWidget {
|
|||||||
this.onHeroParticipate,
|
this.onHeroParticipate,
|
||||||
this.onOpenHistory,
|
this.onOpenHistory,
|
||||||
this.onOpenPlanet,
|
this.onOpenPlanet,
|
||||||
|
this.onTapPlanet,
|
||||||
this.zap,
|
this.zap,
|
||||||
this.zapData,
|
this.zapData,
|
||||||
this.ticker,
|
this.ticker,
|
||||||
@@ -61,6 +63,9 @@ class HomeHero extends StatefulWidget {
|
|||||||
|
|
||||||
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
|
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
|
||||||
final VoidCallback? onOpenPlanet;
|
final VoidCallback? onOpenPlanet;
|
||||||
|
|
||||||
|
/// 행성 본체 터치 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번, 커뮤니티 이동」.
|
||||||
|
final VoidCallback? onTapPlanet;
|
||||||
final String fandomId;
|
final String fandomId;
|
||||||
final PlanetWatch watchPlanet;
|
final PlanetWatch watchPlanet;
|
||||||
final VoidCallback? onHeroParticipate;
|
final VoidCallback? onHeroParticipate;
|
||||||
@@ -207,6 +212,8 @@ class _HomeHeroState extends State<HomeHero> {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
child: _TapShake(
|
||||||
|
onTap: widget.onTapPlanet,
|
||||||
child: LivePlanet25D(
|
child: LivePlanet25D(
|
||||||
onOpenHistory: widget.onOpenHistory,
|
onOpenHistory: widget.onOpenHistory,
|
||||||
key: _planetKey,
|
key: _planetKey,
|
||||||
@@ -222,6 +229,7 @@ class _HomeHeroState extends State<HomeHero> {
|
|||||||
glow: AppColors.neonViolet,
|
glow: AppColors.neonViolet,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -265,6 +273,7 @@ class _HomeHeroState extends State<HomeHero> {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: ZapFeedback(
|
child: ZapFeedback(
|
||||||
|
fandomName: widget.growth.fandomName,
|
||||||
touchSerial: zap.touchSerial,
|
touchSerial: zap.touchSerial,
|
||||||
pending: zap.pending > 0 || zap.sending,
|
pending: zap.pending > 0 || zap.sending,
|
||||||
result: zap.result,
|
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<void> _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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,6 +192,20 @@ class _RankingBody extends StatelessWidget {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(AppSpace.s4),
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
children: [
|
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?) ...[
|
if (data.mine case final me?) ...[
|
||||||
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
|
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
|
||||||
const SizedBox(height: AppSpace.s4),
|
const SizedBox(height: AppSpace.s4),
|
||||||
|
|||||||
@@ -123,10 +123,29 @@ String countryName(String code) {
|
|||||||
|
|
||||||
/// 랭킹 한 판.
|
/// 랭킹 한 판.
|
||||||
class FpiRanking {
|
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<FpiRankRow> rows;
|
final List<FpiRankRow> 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` 「나의 랭킹 영역」.
|
/// Figma `546:14628` 「소속 행성 지표 랭킹 영역」 · `546:14668` 「나의 랭킹 영역」.
|
||||||
final FpiRankRow? mine;
|
final FpiRankRow? mine;
|
||||||
@@ -251,8 +270,14 @@ class PlanetDetail {
|
|||||||
this.fpiUpdatedAt,
|
this.fpiUpdatedAt,
|
||||||
this.joined = true,
|
this.joined = true,
|
||||||
this.growthPop = 0,
|
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 탭 「성운 성장률」의 분자.
|
/// 성운 누적 POP(`fandoms.growthPop`) — 성운 FPI 탭 「성운 성장률」의 분자.
|
||||||
final int growthPop;
|
final int growthPop;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import '../../core/ui/app_icons.dart';
|
|||||||
import '../eclipse/eclipse_data.dart';
|
import '../eclipse/eclipse_data.dart';
|
||||||
import '../eclipse/widgets/eclipse_banner.dart';
|
import '../eclipse/widgets/eclipse_banner.dart';
|
||||||
import '../home/home_screen.dart' show formatPop;
|
import '../home/home_screen.dart' show formatPop;
|
||||||
import '../home/home_data.dart' show defaultNebulaTargets;
|
|
||||||
import 'lounge_post_screens.dart';
|
import 'lounge_post_screens.dart';
|
||||||
import 'moderation.dart';
|
import 'moderation.dart';
|
||||||
import 'planet_data.dart';
|
import 'planet_data.dart';
|
||||||
@@ -41,8 +40,12 @@ class PlanetDetailScreen extends StatefulWidget {
|
|||||||
this.watchPlanet = noPlanetWatch,
|
this.watchPlanet = noPlanetWatch,
|
||||||
required this.repository,
|
required this.repository,
|
||||||
required this.eclipse,
|
required this.eclipse,
|
||||||
|
this.initialTab = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 처음 열 탭 — 0 FPI · 1 팬덤 라운지. HOME 행성 터치는 커뮤니티(라운지)로 연다(Figma `2024:20758`).
|
||||||
|
final int initialTab;
|
||||||
|
|
||||||
/// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §P-08).
|
/// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §P-08).
|
||||||
final bool nebula;
|
final bool nebula;
|
||||||
final PlanetRepository repository;
|
final PlanetRepository repository;
|
||||||
@@ -129,6 +132,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
|||||||
final top = MediaQuery.paddingOf(context).top;
|
final top = MediaQuery.paddingOf(context).top;
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
|
initialIndex: widget.initialTab.clamp(0, 1),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: NestedScrollView(
|
body: NestedScrollView(
|
||||||
headerSliverBuilder: (context, _) => [
|
headerSliverBuilder: (context, _) => [
|
||||||
@@ -431,7 +435,13 @@ class _FpiTab extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
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`.
|
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
|
||||||
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
|
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
|
||||||
class _NebulaFpi extends StatelessWidget {
|
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;
|
final int pop;
|
||||||
|
|
||||||
|
/// 전환 목표 — 서버 `config/policy`(B-10). 탄생은 POP · 창립 인원 **두 조건**이다.
|
||||||
|
final int target;
|
||||||
|
final int members;
|
||||||
|
final int targetMembers;
|
||||||
final PlanetRepository repository;
|
final PlanetRepository repository;
|
||||||
|
|
||||||
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
|
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
|
||||||
@@ -550,8 +571,7 @@ class _NebulaFpi extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final target = defaultNebulaTargets.pop;
|
final rate = target <= 0 ? 0 : (pop * 100 ~/ target).clamp(0, 100);
|
||||||
final rate = pop * 100 ~/ target;
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -593,7 +613,7 @@ class _NebulaFpi extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: pop / target,
|
value: target <= 0 ? 0 : (pop / target).clamp(0.0, 1.0),
|
||||||
minHeight: 10,
|
minHeight: 10,
|
||||||
backgroundColor: Colors.black.withValues(alpha: 0.3),
|
backgroundColor: Colors.black.withValues(alpha: 0.3),
|
||||||
color: AppColors.cosmicTeal,
|
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)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../core/data/backend.dart';
|
|||||||
import '../campaign/campaign_data.dart' show ContributorRank;
|
import '../campaign/campaign_data.dart' show ContributorRank;
|
||||||
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
|
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
|
||||||
import '../my/my_repository_firestore.dart';
|
import '../my/my_repository_firestore.dart';
|
||||||
|
import '../home/home_data.dart' show nebulaTargetsFrom;
|
||||||
import 'moderation.dart' show reportReasonCodes;
|
import 'moderation.dart' show reportReasonCodes;
|
||||||
import 'planet_data.dart';
|
import 'planet_data.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
@@ -165,6 +166,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get();
|
||||||
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
|
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
|
||||||
|
// 성운 전환 목표는 서버 판정과 같은 운영값을 읽는다(앱 상수 아님 · B-10).
|
||||||
|
final policyF = db.doc('config/policy').get();
|
||||||
_cursor = null;
|
_cursor = null;
|
||||||
final postsF = _page(pageSize);
|
final postsF = _page(pageSize);
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
final memberSnap = await memberF;
|
final memberSnap = await memberF;
|
||||||
final member = memberSnap.data() ?? const <String, dynamic>{};
|
final member = memberSnap.data() ?? const <String, dynamic>{};
|
||||||
final posts = await postsF;
|
final posts = await postsF;
|
||||||
|
final targets = nebulaTargetsFrom((await policyF).data());
|
||||||
|
|
||||||
final axes = _map(fandom, 'fpiAxes');
|
final axes = _map(fandom, 'fpiAxes');
|
||||||
final ranks = _map(fandom, 'fpiRanks');
|
final ranks = _map(fandom, 'fpiRanks');
|
||||||
@@ -212,6 +216,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
isNebula: stage == null ? nebula : stage == 0,
|
isNebula: stage == null ? nebula : stage == 0,
|
||||||
members: fandom.intOr('memberCount'),
|
members: fandom.intOr('memberCount'),
|
||||||
growthPop: fandom.intOr('growthPop'),
|
growthPop: fandom.intOr('growthPop'),
|
||||||
|
nebulaTargetPop: targets.pop,
|
||||||
|
nebulaTargetMembers: targets.members,
|
||||||
scores: [
|
scores: [
|
||||||
for (final metric in FpiMetric.values)
|
for (final metric in FpiMetric.values)
|
||||||
FpiScore(
|
FpiScore(
|
||||||
@@ -236,10 +242,22 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 행성 랭킹 — 서버 1분 집계(`fnFpiAggregate`)가 적은 `fpiUpdatedAt` 중 가장 늦은 값이 마지막 갱신이다.
|
||||||
@override
|
@override
|
||||||
Future<FpiRanking> planetRanking({
|
Future<FpiRanking> planetRanking({
|
||||||
required FpiMetric metric,
|
required FpiMetric metric,
|
||||||
int limit = 30,
|
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<FpiRanking> _planetRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
required int limit,
|
||||||
}) async {
|
}) async {
|
||||||
final axis = _axisKey(metric);
|
final axis = _axisKey(metric);
|
||||||
// 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」.
|
// 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」.
|
||||||
@@ -310,11 +328,22 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 사용자 랭킹 — 서버 5분 집계(`fnRankingCountryAggregate`)가 팬덤 문서에 적는 `memberRankedAt`.
|
||||||
@override
|
@override
|
||||||
Future<FpiRanking> userRanking({
|
Future<FpiRanking> userRanking({
|
||||||
required FpiMetric metric,
|
required FpiMetric metric,
|
||||||
required String fandomId,
|
required String fandomId,
|
||||||
int limit = 30,
|
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<FpiRanking> _userRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
required String fandomId,
|
||||||
|
required int limit,
|
||||||
}) async {
|
}) async {
|
||||||
final axis = _axisKey(metric);
|
final axis = _axisKey(metric);
|
||||||
final uid = _backend.requireUid;
|
final uid = _backend.requireUid;
|
||||||
@@ -470,6 +499,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
return FpiRanking(
|
return FpiRanking(
|
||||||
rows: list,
|
rows: list,
|
||||||
mine: list.where((r) => r.isMine).firstOrNull,
|
mine: list.where((r) => r.isMine).firstOrNull,
|
||||||
|
updatedAt: _time(doc.data()?['updatedAt']),
|
||||||
|
refreshMinutes: 5,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@
|
|||||||
"eclipseBannerDefense": "Valid participants {count} / {target}",
|
"eclipseBannerDefense": "Valid participants {count} / {target}",
|
||||||
"timeHoursMinutesLeft": "{hours}h {minutes}m left",
|
"timeHoursMinutesLeft": "{hours}h {minutes}m left",
|
||||||
"timeMinutesLeft": "{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!",
|
"liveHotTime": "[LIVE] {name}'s Artist Hot Time has started!",
|
||||||
"liveHeroGift": "[LIVE] A Hero's Gift from {name} has arrived!",
|
"liveHeroGift": "[LIVE] A Hero's Gift from {name} has arrived!",
|
||||||
"liveCampaign": "[LIVE] {name} backed a campaign!",
|
"liveCampaign": "[LIVE] {name} backed a campaign!",
|
||||||
@@ -235,6 +235,7 @@
|
|||||||
"zapDone": "Contributed",
|
"zapDone": "Contributed",
|
||||||
"zapDonePop": "Contributed {pop} POP",
|
"zapDonePop": "Contributed {pop} POP",
|
||||||
"growthFounders": "Founding members",
|
"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",
|
"growthNebulaGoalPassed": "The goal period has passed · Meet the conditions and the planet will be born",
|
||||||
"growthFinalStage": "Final stage",
|
"growthFinalStage": "Final stage",
|
||||||
"growthPromotionPending": "Awaiting promotion",
|
"growthPromotionPending": "Awaiting promotion",
|
||||||
@@ -486,6 +487,7 @@
|
|||||||
"rankEmpty": "No rankings yet",
|
"rankEmpty": "No rankings yet",
|
||||||
"rankScope": "{scope} ranking",
|
"rankScope": "{scope} ranking",
|
||||||
"rankLoadFailed": "Couldn't load the ranking",
|
"rankLoadFailed": "Couldn't load the ranking",
|
||||||
|
"rankRefreshEvery": "Updates every {minutes} min",
|
||||||
"rankMeSuffix": "{name} (me)",
|
"rankMeSuffix": "{name} (me)",
|
||||||
"reportDefamation": "Artist slander or defamation",
|
"reportDefamation": "Artist slander or defamation",
|
||||||
"reportInappropriate": "Inappropriate content",
|
"reportInappropriate": "Inappropriate content",
|
||||||
|
|||||||
@@ -201,7 +201,7 @@
|
|||||||
"eclipseBannerDefense": "Participantes válidos {count} / {target}",
|
"eclipseBannerDefense": "Participantes válidos {count} / {target}",
|
||||||
"timeHoursMinutesLeft": "Quedan {hours} h {minutes} min",
|
"timeHoursMinutesLeft": "Quedan {hours} h {minutes} min",
|
||||||
"timeMinutesLeft": "Quedan {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}!",
|
"liveHotTime": "[LIVE] ¡Comenzó el Artist Hot Time de {name}!",
|
||||||
"liveHeroGift": "[LIVE] ¡Llegó el Regalo del Héroe de {name}!",
|
"liveHeroGift": "[LIVE] ¡Llegó el Regalo del Héroe de {name}!",
|
||||||
"liveCampaign": "[LIVE] ¡{name} apoyó una campaña!",
|
"liveCampaign": "[LIVE] ¡{name} apoyó una campaña!",
|
||||||
@@ -235,6 +235,7 @@
|
|||||||
"zapDone": "Contribución completada",
|
"zapDone": "Contribución completada",
|
||||||
"zapDonePop": "{pop} POP contribuidos",
|
"zapDonePop": "{pop} POP contribuidos",
|
||||||
"growthFounders": "Miembros fundadores",
|
"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",
|
"growthNebulaGoalPassed": "El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta",
|
||||||
"growthFinalStage": "Etapa final",
|
"growthFinalStage": "Etapa final",
|
||||||
"growthPromotionPending": "Ascenso pendiente",
|
"growthPromotionPending": "Ascenso pendiente",
|
||||||
@@ -486,6 +487,7 @@
|
|||||||
"rankEmpty": "Aún no hay ranking",
|
"rankEmpty": "Aún no hay ranking",
|
||||||
"rankScope": "Ranking {scope}",
|
"rankScope": "Ranking {scope}",
|
||||||
"rankLoadFailed": "No se pudo cargar el ranking",
|
"rankLoadFailed": "No se pudo cargar el ranking",
|
||||||
|
"rankRefreshEvery": "Se actualiza cada {minutes} min",
|
||||||
"rankMeSuffix": "{name} (yo)",
|
"rankMeSuffix": "{name} (yo)",
|
||||||
"reportDefamation": "Difamación o insultos al artista",
|
"reportDefamation": "Difamación o insultos al artista",
|
||||||
"reportInappropriate": "Contenido inapropiado",
|
"reportInappropriate": "Contenido inapropiado",
|
||||||
|
|||||||
@@ -201,7 +201,7 @@
|
|||||||
"eclipseBannerDefense": "有効参加 {count} / {target}人",
|
"eclipseBannerDefense": "有効参加 {count} / {target}人",
|
||||||
"timeHoursMinutesLeft": "残り{hours}時間{minutes}分",
|
"timeHoursMinutesLeft": "残り{hours}時間{minutes}分",
|
||||||
"timeMinutesLeft": "残り{minutes}分",
|
"timeMinutesLeft": "残り{minutes}分",
|
||||||
"liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!(+{count})",
|
"liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!",
|
||||||
"liveHotTime": "[LIVE] {name}さんのアーティストホットタイムが始まりました!",
|
"liveHotTime": "[LIVE] {name}さんのアーティストホットタイムが始まりました!",
|
||||||
"liveHeroGift": "[LIVE] {name}さんの英雄のギフトが届きました!",
|
"liveHeroGift": "[LIVE] {name}さんの英雄のギフトが届きました!",
|
||||||
"liveCampaign": "[LIVE] {name}さんがキャンペーンを支援しました!",
|
"liveCampaign": "[LIVE] {name}さんがキャンペーンを支援しました!",
|
||||||
@@ -235,6 +235,7 @@
|
|||||||
"zapDone": "貢献完了",
|
"zapDone": "貢献完了",
|
||||||
"zapDonePop": "{pop} POP 貢献完了",
|
"zapDonePop": "{pop} POP 貢献完了",
|
||||||
"growthFounders": "創立メンバー",
|
"growthFounders": "創立メンバー",
|
||||||
|
"zapReflected": "{pop} POP が {fandom} の成長に反映されました",
|
||||||
"growthNebulaGoalPassed": "目標期間が過ぎました · 条件を満たすと惑星が誕生します",
|
"growthNebulaGoalPassed": "目標期間が過ぎました · 条件を満たすと惑星が誕生します",
|
||||||
"growthFinalStage": "最終ステージ",
|
"growthFinalStage": "最終ステージ",
|
||||||
"growthPromotionPending": "昇格確定待ち",
|
"growthPromotionPending": "昇格確定待ち",
|
||||||
@@ -486,6 +487,7 @@
|
|||||||
"rankEmpty": "まだ集計されたランキングはありません",
|
"rankEmpty": "まだ集計されたランキングはありません",
|
||||||
"rankScope": "{scope}ランキング",
|
"rankScope": "{scope}ランキング",
|
||||||
"rankLoadFailed": "ランキングを読み込めませんでした",
|
"rankLoadFailed": "ランキングを読み込めませんでした",
|
||||||
|
"rankRefreshEvery": "{minutes}分ごとに更新",
|
||||||
"rankMeSuffix": "{name}(自分)",
|
"rankMeSuffix": "{name}(自分)",
|
||||||
"reportDefamation": "アーティストへの誹謗・名誉毀損",
|
"reportDefamation": "アーティストへの誹謗・名誉毀損",
|
||||||
"reportInappropriate": "不適切なコンテンツ",
|
"reportInappropriate": "不適切なコンテンツ",
|
||||||
|
|||||||
+20
-4
@@ -422,14 +422,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveEclipseDefense": "[LIVE] {name}님이 이클립스 방어에 참여했습니다! (+{count})",
|
"liveEclipseDefense": "[LIVE] {name}님이 이클립스 방어에 합류했습니다!",
|
||||||
"@liveEclipseDefense": {
|
"@liveEclipseDefense": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
},
|
|
||||||
"count": {
|
|
||||||
"type": "String"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -531,6 +528,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"growthFounders": "창립 멤버",
|
"growthFounders": "창립 멤버",
|
||||||
|
"zapReflected": "{pop} POP 이 {fandom} 성장에 반영되었습니다",
|
||||||
|
"@zapReflected": {
|
||||||
|
"placeholders": {
|
||||||
|
"pop": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"fandom": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"growthNebulaGoalPassed": "목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요",
|
"growthNebulaGoalPassed": "목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요",
|
||||||
"growthFinalStage": "최종 Stage",
|
"growthFinalStage": "최종 Stage",
|
||||||
"growthPromotionPending": "승급 확정 대기",
|
"growthPromotionPending": "승급 확정 대기",
|
||||||
@@ -966,6 +974,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rankLoadFailed": "랭킹을 불러오지 못했습니다",
|
"rankLoadFailed": "랭킹을 불러오지 못했습니다",
|
||||||
|
"rankRefreshEvery": "{minutes}분마다 갱신",
|
||||||
|
"@rankRefreshEvery": {
|
||||||
|
"placeholders": {
|
||||||
|
"minutes": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"rankMeSuffix": "{name} (나)",
|
"rankMeSuffix": "{name} (나)",
|
||||||
"@rankMeSuffix": {
|
"@rankMeSuffix": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -1313,8 +1313,8 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @liveEclipseDefense.
|
/// No description provided for @liveEclipseDefense.
|
||||||
///
|
///
|
||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
/// **'[LIVE] {name}님이 이클립스 방어에 참여했습니다! (+{count})'**
|
/// **'[LIVE] {name}님이 이클립스 방어에 합류했습니다!'**
|
||||||
String liveEclipseDefense(String name, String count);
|
String liveEclipseDefense(String name);
|
||||||
|
|
||||||
/// No description provided for @liveHotTime.
|
/// No description provided for @liveHotTime.
|
||||||
///
|
///
|
||||||
@@ -1514,6 +1514,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'창립 멤버'**
|
/// **'창립 멤버'**
|
||||||
String get growthFounders;
|
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.
|
/// No description provided for @growthNebulaGoalPassed.
|
||||||
///
|
///
|
||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
@@ -3020,6 +3026,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'랭킹을 불러오지 못했습니다'**
|
/// **'랭킹을 불러오지 못했습니다'**
|
||||||
String get rankLoadFailed;
|
String get rankLoadFailed;
|
||||||
|
|
||||||
|
/// No description provided for @rankRefreshEvery.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'{minutes}분마다 갱신'**
|
||||||
|
String rankRefreshEvery(String minutes);
|
||||||
|
|
||||||
/// No description provided for @rankMeSuffix.
|
/// No description provided for @rankMeSuffix.
|
||||||
///
|
///
|
||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
|
|||||||
@@ -728,8 +728,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String liveEclipseDefense(String name, String count) {
|
String liveEclipseDefense(String name) {
|
||||||
return '[LIVE] $name joined the eclipse defense! (+$count)';
|
return '[LIVE] $name joined the eclipse defense!';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -850,6 +850,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get growthFounders => 'Founding members';
|
String get growthFounders => 'Founding members';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String zapReflected(String pop, String fandom) {
|
||||||
|
return '$pop POP added to $fandom\'s growth';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthNebulaGoalPassed =>
|
String get growthNebulaGoalPassed =>
|
||||||
'The goal period has passed · Meet the conditions and the planet will be born';
|
'The goal period has passed · Meet the conditions and the planet will be born';
|
||||||
@@ -1680,6 +1685,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rankLoadFailed => 'Couldn\'t load the ranking';
|
String get rankLoadFailed => 'Couldn\'t load the ranking';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String rankRefreshEvery(String minutes) {
|
||||||
|
return 'Updates every $minutes min';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String rankMeSuffix(String name) {
|
String rankMeSuffix(String name) {
|
||||||
return '$name (me)';
|
return '$name (me)';
|
||||||
|
|||||||
@@ -738,8 +738,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String liveEclipseDefense(String name, String count) {
|
String liveEclipseDefense(String name) {
|
||||||
return '[LIVE] ¡$name se unió a la defensa del eclipse! (+$count)';
|
return '[LIVE] ¡$name se unió a la defensa del eclipse!';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -860,6 +860,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get growthFounders => 'Miembros fundadores';
|
String get growthFounders => 'Miembros fundadores';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String zapReflected(String pop, String fandom) {
|
||||||
|
return '$pop POP se sumaron al crecimiento de $fandom';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthNebulaGoalPassed =>
|
String get growthNebulaGoalPassed =>
|
||||||
'El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta';
|
'El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta';
|
||||||
@@ -1688,6 +1693,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rankLoadFailed => 'No se pudo cargar el ranking';
|
String get rankLoadFailed => 'No se pudo cargar el ranking';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String rankRefreshEvery(String minutes) {
|
||||||
|
return 'Se actualiza cada $minutes min';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String rankMeSuffix(String name) {
|
String rankMeSuffix(String name) {
|
||||||
return '$name (yo)';
|
return '$name (yo)';
|
||||||
|
|||||||
@@ -698,8 +698,8 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String liveEclipseDefense(String name, String count) {
|
String liveEclipseDefense(String name) {
|
||||||
return '[LIVE] $nameさんがイクリプス防衛に参加しました!(+$count)';
|
return '[LIVE] $nameさんがイクリプス防衛に参加しました!';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -817,6 +817,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get growthFounders => '創立メンバー';
|
String get growthFounders => '創立メンバー';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String zapReflected(String pop, String fandom) {
|
||||||
|
return '$pop POP が $fandom の成長に反映されました';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthNebulaGoalPassed => '目標期間が過ぎました · 条件を満たすと惑星が誕生します';
|
String get growthNebulaGoalPassed => '目標期間が過ぎました · 条件を満たすと惑星が誕生します';
|
||||||
|
|
||||||
@@ -1622,6 +1627,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rankLoadFailed => 'ランキングを読み込めませんでした';
|
String get rankLoadFailed => 'ランキングを読み込めませんでした';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String rankRefreshEvery(String minutes) {
|
||||||
|
return '$minutes分ごとに更新';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String rankMeSuffix(String name) {
|
String rankMeSuffix(String name) {
|
||||||
return '$name(自分)';
|
return '$name(自分)';
|
||||||
|
|||||||
@@ -698,8 +698,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String liveEclipseDefense(String name, String count) {
|
String liveEclipseDefense(String name) {
|
||||||
return '[LIVE] $name님이 이클립스 방어에 참여했습니다! (+$count)';
|
return '[LIVE] $name님이 이클립스 방어에 합류했습니다!';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -817,6 +817,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get growthFounders => '창립 멤버';
|
String get growthFounders => '창립 멤버';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String zapReflected(String pop, String fandom) {
|
||||||
|
return '$pop POP 이 $fandom 성장에 반영되었습니다';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthNebulaGoalPassed => '목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요';
|
String get growthNebulaGoalPassed => '목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요';
|
||||||
|
|
||||||
@@ -1623,6 +1628,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rankLoadFailed => '랭킹을 불러오지 못했습니다';
|
String get rankLoadFailed => '랭킹을 불러오지 못했습니다';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String rankRefreshEvery(String minutes) {
|
||||||
|
return '$minutes분마다 갱신';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String rankMeSuffix(String name) {
|
String rankMeSuffix(String name) {
|
||||||
return '$name (나)';
|
return '$name (나)';
|
||||||
|
|||||||
@@ -698,8 +698,8 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String liveEclipseDefense(String name, String count) {
|
String liveEclipseDefense(String name) {
|
||||||
return '[LIVE] $name 参与了日蚀防御!(+$count)';
|
return '[LIVE] $name 加入了日蚀防御!';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -817,6 +817,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get growthFounders => '创始成员';
|
String get growthFounders => '创始成员';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String zapReflected(String pop, String fandom) {
|
||||||
|
return '$pop POP 已计入 $fandom 的成长';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthNebulaGoalPassed => '目标期限已过 · 满足条件即可诞生行星';
|
String get growthNebulaGoalPassed => '目标期限已过 · 满足条件即可诞生行星';
|
||||||
|
|
||||||
@@ -1621,6 +1626,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rankLoadFailed => '无法加载排名';
|
String get rankLoadFailed => '无法加载排名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String rankRefreshEvery(String minutes) {
|
||||||
|
return '每$minutes分钟更新';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String rankMeSuffix(String name) {
|
String rankMeSuffix(String name) {
|
||||||
return '$name(我)';
|
return '$name(我)';
|
||||||
|
|||||||
@@ -201,7 +201,7 @@
|
|||||||
"eclipseBannerDefense": "有效参与 {count} / {target} 人",
|
"eclipseBannerDefense": "有效参与 {count} / {target} 人",
|
||||||
"timeHoursMinutesLeft": "剩余 {hours} 小时 {minutes} 分",
|
"timeHoursMinutesLeft": "剩余 {hours} 小时 {minutes} 分",
|
||||||
"timeMinutesLeft": "剩余 {minutes} 分",
|
"timeMinutesLeft": "剩余 {minutes} 分",
|
||||||
"liveEclipseDefense": "[LIVE] {name} 参与了日蚀防御!(+{count})",
|
"liveEclipseDefense": "[LIVE] {name} 加入了日蚀防御!",
|
||||||
"liveHotTime": "[LIVE] {name} 的艺人热力时刻开始了!",
|
"liveHotTime": "[LIVE] {name} 的艺人热力时刻开始了!",
|
||||||
"liveHeroGift": "[LIVE] {name} 的英雄礼物到了!",
|
"liveHeroGift": "[LIVE] {name} 的英雄礼物到了!",
|
||||||
"liveCampaign": "[LIVE] {name} 支持了一个活动!",
|
"liveCampaign": "[LIVE] {name} 支持了一个活动!",
|
||||||
@@ -235,6 +235,7 @@
|
|||||||
"zapDone": "贡献完成",
|
"zapDone": "贡献完成",
|
||||||
"zapDonePop": "已贡献 {pop} POP",
|
"zapDonePop": "已贡献 {pop} POP",
|
||||||
"growthFounders": "创始成员",
|
"growthFounders": "创始成员",
|
||||||
|
"zapReflected": "{pop} POP 已计入 {fandom} 的成长",
|
||||||
"growthNebulaGoalPassed": "目标期限已过 · 满足条件即可诞生行星",
|
"growthNebulaGoalPassed": "目标期限已过 · 满足条件即可诞生行星",
|
||||||
"growthFinalStage": "最终阶段",
|
"growthFinalStage": "最终阶段",
|
||||||
"growthPromotionPending": "等待晋级确认",
|
"growthPromotionPending": "等待晋级确认",
|
||||||
@@ -486,6 +487,7 @@
|
|||||||
"rankEmpty": "暂无排名数据",
|
"rankEmpty": "暂无排名数据",
|
||||||
"rankScope": "{scope}排名",
|
"rankScope": "{scope}排名",
|
||||||
"rankLoadFailed": "无法加载排名",
|
"rankLoadFailed": "无法加载排名",
|
||||||
|
"rankRefreshEvery": "每{minutes}分钟更新",
|
||||||
"rankMeSuffix": "{name}(我)",
|
"rankMeSuffix": "{name}(我)",
|
||||||
"reportDefamation": "诽谤艺人及损害名誉",
|
"reportDefamation": "诽谤艺人及损害名誉",
|
||||||
"reportInappropriate": "不当内容",
|
"reportInappropriate": "不当内容",
|
||||||
|
|||||||
@@ -1157,7 +1157,7 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
String liveCampaign(String name) => _fill('liveCampaign', {'name': name}) ?? _base.liveCampaign(name);
|
String liveCampaign(String name) => _fill('liveCampaign', {'name': name}) ?? _base.liveCampaign(name);
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String liveHeroGift(String name) => _fill('liveHeroGift', {'name': name}) ?? _base.liveHeroGift(name);
|
String liveHeroGift(String name) => _fill('liveHeroGift', {'name': name}) ?? _base.liveHeroGift(name);
|
||||||
@@ -2035,6 +2035,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rankPlanetEmpty => _remote['rankPlanetEmpty'] ?? _base.rankPlanetEmpty;
|
String get rankPlanetEmpty => _remote['rankPlanetEmpty'] ?? _base.rankPlanetEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String rankRefreshEvery(String minutes) => _fill('rankRefreshEvery', {'minutes': minutes}) ?? _base.rankRefreshEvery(minutes);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String rankScope(String scope) => _fill('rankScope', {'scope': scope}) ?? _base.rankScope(scope);
|
String rankScope(String scope) => _fill('rankScope', {'scope': scope}) ?? _base.rankScope(scope);
|
||||||
|
|
||||||
@@ -2461,6 +2464,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get zapNotEnoughPop => _remote['zapNotEnoughPop'] ?? _base.zapNotEnoughPop;
|
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
|
@override
|
||||||
String get zapRetrySame => _remote['zapRetrySame'] ?? _base.zapRetrySame;
|
String get zapRetrySame => _remote['zapRetrySame'] ?? _base.zapRetrySame;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ void main() {
|
|||||||
expect(home.growth.requiredSfpi, 16575);
|
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 {
|
test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async {
|
||||||
final db = await _seed();
|
final db = await _seed();
|
||||||
await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'});
|
await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'});
|
||||||
@@ -134,10 +152,13 @@ void main() {
|
|||||||
expect(detail.stage, 4);
|
expect(detail.stage, 4);
|
||||||
expect(detail.isNebula, false);
|
expect(detail.isNebula, false);
|
||||||
await db.doc('fandoms/bts').update({'stage': 0});
|
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')
|
final nebula = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
|
||||||
.load(nebula: false);
|
.load(nebula: false);
|
||||||
expect(nebula.stage, 0);
|
expect(nebula.stage, 0);
|
||||||
expect(nebula.isNebula, true);
|
expect(nebula.isNebula, true);
|
||||||
|
expect([nebula.nebulaTargetPop, nebula.nebulaTargetMembers], [2000, 12]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('서버에 단계가 없으면 시작 단계로 지어내지 않는다', () async {
|
test('서버에 단계가 없으면 시작 단계로 지어내지 않는다', () async {
|
||||||
@@ -559,6 +580,20 @@ void main() {
|
|||||||
expect(ranking.mine!.change, 1);
|
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 {
|
test('랭킹 모집단은 최근 7일 유효 활동 행성뿐이다 — 별첨 B', () async {
|
||||||
final db = await seedPlanet();
|
final db = await seedPlanet();
|
||||||
final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
|
final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ void main() {
|
|||||||
await _settle(tester);
|
await _settle(tester);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.text('[LIVE] 버니즈님이 이클립스 방어에 참여했습니다! (+30)'),
|
find.text('[LIVE] 버니즈님이 이클립스 방어에 합류했습니다!'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -269,7 +269,7 @@ void main() {
|
|||||||
|
|
||||||
expect(find.textContaining('버니즈'), findsNothing);
|
expect(find.textContaining('버니즈'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
find.text('[LIVE] 캐럿님이 이클립스 방어에 참여했습니다! (+50)'),
|
find.text('[LIVE] 캐럿님이 이클립스 방어에 합류했습니다!'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -304,7 +304,7 @@ void main() {
|
|||||||
// 이벤트가 오면 그 문구가 폴백을 대체한다.
|
// 이벤트가 오면 그 문구가 폴백을 대체한다.
|
||||||
expect(find.textContaining('아이템 사용으로 버프가 발동'), findsNothing);
|
expect(find.textContaining('아이템 사용으로 버프가 발동'), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
find.text('[LIVE] 무민님이 이클립스 방어에 참여했습니다! (+20)'),
|
find.text('[LIVE] 무민님이 이클립스 방어에 합류했습니다!'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -313,7 +313,7 @@ void main() {
|
|||||||
final at = DateTime(2026, 9, 14);
|
final at = DateTime(2026, 9, 14);
|
||||||
expect(
|
expect(
|
||||||
liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)),
|
liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)),
|
||||||
'[LIVE] 나님이 이클립스 방어에 참여했습니다! (+1,200)',
|
'[LIVE] 나님이 이클립스 방어에 합류했습니다!',
|
||||||
);
|
);
|
||||||
// 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다.
|
// 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ class RecordingGalaxyRepository implements GalaxyRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> setPrimary(String fandomId) async {}
|
Future<void> setPrimary(String fandomId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PlanetNode>> search(String query) =>
|
||||||
|
const SampleGalaxyRepository().search(query);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<GalaxySnapshot> load() async =>
|
Future<GalaxySnapshot> load() async =>
|
||||||
snapshot ?? await const SampleGalaxyRepository().load();
|
snapshot ?? await const SampleGalaxyRepository().load();
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class _TopOrbitRepository implements GalaxyRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> setPrimary(String fandomId) async {}
|
Future<void> setPrimary(String fandomId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PlanetNode>> search(String query) async => const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ZapResult> zap({
|
Future<ZapResult> zap({
|
||||||
required String fandomId,
|
required String fandomId,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ void main() {
|
|||||||
expect(repository.counts, [2, 2]);
|
expect(repository.counts, [2, 2]);
|
||||||
repository.replies.last.complete(const ZapResult(txId: 'home-confirmed', balanceAfter: 900, contributedPop: 37));
|
repository.replies.last.complete(const ZapResult(txId: 'home-confirmed', balanceAfter: 900, contributedPop: 37));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('37 POP 기여 완료'), findsOneWidget);
|
expect(find.textContaining('37 POP 이 '), findsOneWidget);
|
||||||
expect(tester.widget<ZapButton>(find.byType(ZapButton)).myPop, 900);
|
expect(tester.widget<ZapButton>(find.byType(ZapButton)).myPop, 900);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
@@ -135,6 +135,17 @@ void main() {
|
|||||||
expect(tester.binding.transientCallbackCount, 0);
|
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 {
|
test('기여 결과는 현재 단가가 아니라 서버가 확정한 차감 lot 합계다', () async {
|
||||||
final db = FakeFirebaseFirestore();
|
final db = FakeFirebaseFirestore();
|
||||||
await db.doc('config/policy').set({'zapPop': 10});
|
await db.doc('config/policy').set({'zapPop': 10});
|
||||||
@@ -156,14 +167,14 @@ void main() {
|
|||||||
);
|
);
|
||||||
repository.replies.last.complete(receipt);
|
repository.replies.last.complete(receipt);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('37 POP 기여 완료'), findsOneWidget);
|
expect(find.textContaining('37 POP 이 '), findsOneWidget);
|
||||||
await tester.pump(const Duration(seconds: 2));
|
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.tap(find.byType(ZapButton));
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
repository.replies.last.complete(receipt);
|
repository.replies.last.complete(receipt);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('37 POP 기여 완료'), findsNothing);
|
expect(find.textContaining('37 POP 이 '), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('ZAP 옵션은 2초 전에 열리지 않고 길게 누르면 차감하지 않는다', (tester) async {
|
testWidgets('ZAP 옵션은 2초 전에 열리지 않고 길게 누르면 차감하지 않는다', (tester) async {
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
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)', () {
|
test('컬러는 마스터(config/planetColors)가 정한다 — 추가 색은 그리고 숨긴 색은 고르지 못한다 (10/3 #6)', () {
|
||||||
final master = planetColorsFrom({'colors': [
|
final master = planetColorsFrom({'colors': [
|
||||||
{'key': 'gold', 'hex': '#FFC500', 'visible': false},
|
{'key': 'gold', 'hex': '#FFC500', 'visible': false},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fancake/core/theme/app_theme.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/home_hero.dart';
|
||||||
import 'package:fancake/features/home/widgets/growth_conditions.dart';
|
import 'package:fancake/features/home/widgets/growth_conditions.dart';
|
||||||
import 'package:fancake/features/home/widgets/planet_growth_card.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';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
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 {
|
testWidgets('기여 기록이 없으면 두 성장 영역에 가짜 0 POP 기여를 표시하지 않는다', (tester) async {
|
||||||
const growth = PlanetGrowth(fandomName: 'BTS', contributorName: '팬',
|
const growth = PlanetGrowth(fandomName: 'BTS', contributorName: '팬',
|
||||||
contributedPop: 0, growthPercent: null, currentPop: null, requiredPop: null);
|
contributedPop: 0, growthPercent: null, currentPop: null, requiredPop: null);
|
||||||
|
|||||||
Reference in New Issue
Block a user