diff --git a/app/assets/figma/planet-shape-1.png b/app/assets/figma/planet-shape-1.png index 9aa2fb2..2413886 100644 Binary files a/app/assets/figma/planet-shape-1.png and b/app/assets/figma/planet-shape-1.png differ diff --git a/app/assets/figma/planet-shape-2.png b/app/assets/figma/planet-shape-2.png index bdeec45..be332dc 100644 Binary files a/app/assets/figma/planet-shape-2.png and b/app/assets/figma/planet-shape-2.png differ diff --git a/app/assets/figma/planet-shape-3.png b/app/assets/figma/planet-shape-3.png index c70a7ca..d8e596d 100644 Binary files a/app/assets/figma/planet-shape-3.png and b/app/assets/figma/planet-shape-3.png differ diff --git a/app/assets/figma/planet-shape-4.png b/app/assets/figma/planet-shape-4.png index 6effb01..68e1600 100644 Binary files a/app/assets/figma/planet-shape-4.png and b/app/assets/figma/planet-shape-4.png differ diff --git a/app/assets/figma/planet-shape-5.png b/app/assets/figma/planet-shape-5.png index 5448bc9..079201c 100644 Binary files a/app/assets/figma/planet-shape-5.png and b/app/assets/figma/planet-shape-5.png differ diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 7b9d689..2d62b5f 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -283,6 +283,12 @@ service cloud.firestore { // 활동 로그(걸음·스밍·위치·출석). 어뷰징 판정 입력이라 클라이언트 쓰기 금지. // 화면: S-H-WALK · S-H-STREAM · S-H-PLACE · S-H-ATTEND · S-AD-ABUSE + // 성장 귀속 행(원장과 같은 트랜잭션) — HOME 주간 정보(A-37) 「이번 주 기여량 · 비율」이 본인 행을 합한다. + match /users/{uid}/growthEvents/{txId} { + allow read: if isSelf(uid) || isViewer(); + allow write: if false; + } + match /users/{uid}/activityLogs/{logId} { allow read: if isSelf(uid) || isViewer(); allow write: if false; diff --git a/app/firebase/functions/src/fandom/weekly.ts b/app/firebase/functions/src/fandom/weekly.ts index 7bb22b0..a06db12 100644 --- a/app/firebase/functions/src/fandom/weekly.ts +++ b/app/firebase/functions/src/fandom/weekly.ts @@ -30,12 +30,14 @@ export async function snapshotWeeklyRanking(now = new Date()): Promise<{ week: s const db = getFirestore(); const week = weekKeyOf(now); const fandoms = await db.collection('fandoms').where('rankingEligible', '==', true) - .select('name', 'fpiScore', 'fpiAxes', 'stage', 'level').get(); + .select('name', 'fpiScore', 'fpiAxes', 'stage', 'level', 'growthPop', 'sfpi').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, + // HOME 주간 정보(A-37)의 기준선 — 「이번 주 성장」은 지금 누계에서 이 값을 뺀 두 축 증분이다. + growthPop: Number(item.get('growthPop') ?? 0), sfpi: Number(item.get('sfpi') ?? 0), })); try { await db.doc(`rankingSnapshots/${week}`).create({ diff --git a/app/firebase/functions/test/weekly.test.ts b/app/firebase/functions/test/weekly.test.ts index dbef5db..c3b5a11 100644 --- a/app/firebase/functions/test/weekly.test.ts +++ b/app/firebase/functions/test/weekly.test.ts @@ -8,7 +8,7 @@ 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-a').set({ name: 'A', fpiScore: 300, rankingEligible: true, growthPop: 1200, sfpi: 34 }); 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 }); @@ -36,6 +36,9 @@ it('모집단 행성의 공동 순위를 한 번만 영구 보관한다', async 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]); + // HOME 「이번 주 성장」 기준선(A-37) — 그 시각의 Growth POP · sFPI 누계를 함께 남긴다. + const a = (snap.planets as Array>).find((p) => p.fandomId === 'test-week-a')!; + expect([a.growthPop, a.sfpi]).toEqual([1200, 34]); // 다시 돌아도 덮지 않는다. await db.doc('fandoms/test-week-c').update({ fpiScore: 9_999 }); expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: false }); diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs index 00b2d83..de76aa6 100644 --- a/app/firebase/test/tests/firestore.rules.test.mjs +++ b/app/firebase/test/tests/firestore.rules.test.mjs @@ -253,6 +253,17 @@ describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => { }); }); +describe('성장 귀속 행 — users/{uid}/growthEvents (HOME 주간 정보 A-37)', () => { + test('본인만 읽고 누구도 직접 쓰지 못한다', async () => { + await seedDocuments(testEnvironment, { + 'users/alice/growthEvents/tx-1': { fandomIdAtPayout: 'fandom-a', amount: 10 }, + }); + await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/growthEvents/tx-1'))); + await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/growthEvents/tx-1'))); + await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/growthEvents/tx-2'), { amount: 999 })); + }); +}); + describe('가입 목록 — users/{uid}/memberships (C-15)', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { diff --git a/app/lib/core/data/planet_visual_state.dart b/app/lib/core/data/planet_visual_state.dart index db19017..12dcc4f 100644 --- a/app/lib/core/data/planet_visual_state.dart +++ b/app/lib/core/data/planet_visual_state.dart @@ -11,6 +11,7 @@ class PlanetVisualState { this.heroNicknames = const [], this.heroClaimCount, this.colorKey, + this.planetType, this.evolutionImminent = false, }); final int? stage; @@ -36,6 +37,9 @@ class PlanetVisualState { /// 생성 때 고른 행성 컬러 키(C-50 · `fn-planet-create`). 성운이면 null 이다. final String? colorKey; + + /// 생성 때 고른 행성 형태(C-50 · `fn-planet-create` 가 팬덤 문서에 싣는 `planetType`). 성운이면 null. + final String? planetType; final int? level; final bool isEclipsed; final bool isHibernating; @@ -54,6 +58,7 @@ class PlanetVisualState { PlanetVisualState( isHibernating: data['isHibernating'] == true, colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, + planetType: data['planetType'] is String ? data['planetType'] as String : null, stage: switch (data['stage']) { int n when n >= 0 && n <= 6 => n, _ => null, diff --git a/app/lib/core/data/repositories.dart b/app/lib/core/data/repositories.dart index 64b0bf0..f5fbb6a 100644 --- a/app/lib/core/data/repositories.dart +++ b/app/lib/core/data/repositories.dart @@ -169,6 +169,7 @@ class Repositories { PlanetVisualState( stage: current.stage, colorKey: current.colorKey, + planetType: current.planetType, isHibernating: current.isHibernating, level: current.level, evolutionImminent: current.evolutionImminent, diff --git a/app/lib/core/ui/live_planet.dart b/app/lib/core/ui/live_planet.dart index 822c7e8..0e773a3 100644 --- a/app/lib/core/ui/live_planet.dart +++ b/app/lib/core/ui/live_planet.dart @@ -35,7 +35,8 @@ class LivePlanet25D extends StatelessWidget { create: () => watch(fandomId), builder: (context, state) => Planet25D( key: ValueKey((fandomId, state != null)), - image: image, + // 생성 때 고른 형태가 있으면 그 그림, 없으면(성운 · 형태 없는 옛 행성) 화면이 준 그림. + image: planetShapeImage(state?.planetType) ?? image, onOpenHistory: onOpenHistory, size: size, glow: glow, diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart index 022e534..08f4a51 100644 --- a/app/lib/core/ui/planet_25d.dart +++ b/app/lib/core/ui/planet_25d.dart @@ -59,6 +59,63 @@ Color? planetColorOf(String? key) => _planetColorMaster.where((c) => c.$1 == key).firstOrNull?.$2 ?? planetColors.where((c) => c.$1 == key).firstOrNull?.$2; +/// 생성 때 고른 형태(`planetType`, 서버 `PLANET_SHAPES`) → 형태 그림. 모르는 값 · 성운이면 null. +/// +/// 그림은 Figma 「홈_성장 완료/행성 생성」(`344:214421`) 형태 칸이 쓰는 **행성 형태 시트 이미지 +/// (1536×1024 · 투명 배경 · 2행 5열)** 에서 칸과 같은 형태를 잘라 낸 것이다(2026-10-06 재추출). +/// 칸 1~5 는 시트 1 · 2 · 3 · 4 · 10번째 형태다. 6번째 칸은 Figma 에서 「다음」 버튼에 가려 +/// 어떤 형태인지 보이지 않아 자리표시(1번과 같은 그림)를 둔다 — 해석 확인 요청 Q-A14. +/// 시트 원본이 형태당 약 260px 라 큰 화면에서는 흐릴 수 있다(Admin 등록 이미지로 바뀌면 해소). +String? planetShapeImage(String? planetType) { + final n = int.tryParse( + RegExp(r'^shape([1-6])$').firstMatch(planetType ?? '')?.group(1) ?? '', + ); + if (n == null) return null; + return n <= 5 + ? 'assets/figma/planet-shape-$n.png' + : 'assets/figma/planet-shape-placeholder.png'; +} + +/// 정지 그림 — 형태 · 색을 입힌 행성(목록 카드 · MY). 형태가 없으면 [fallback] 그림 그대로다. +/// 색은 [Planet25D] 와 같은 곱하기 합성이다. +class PlanetArt extends StatelessWidget { + const PlanetArt({ + super.key, + required this.fallback, + this.planetType, + this.colorKey, + this.width, + this.height, + this.semanticLabel, + }); + + final String fallback; + final String? planetType; + final String? colorKey; + final double? width; + final double? height; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final shape = planetShapeImage(planetType); + final image = Image.asset( + shape ?? fallback, + width: width, + height: height, + fit: BoxFit.contain, + semanticLabel: semanticLabel, + ); + final tint = shape == null ? null : planetColorOf(colorKey); + return tint == null + ? image + : ColorFiltered( + colorFilter: ColorFilter.mode(tint, BlendMode.modulate), + child: image, + ); + } +} + /// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다. /// Stage는 서버 확정값이며, null은 단계 데이터 미제공 상태다. class Planet25D extends StatefulWidget { diff --git a/app/lib/features/galaxy/galaxy_data.dart b/app/lib/features/galaxy/galaxy_data.dart index 254366f..1b081a9 100644 --- a/app/lib/features/galaxy/galaxy_data.dart +++ b/app/lib/features/galaxy/galaxy_data.dart @@ -19,6 +19,7 @@ class PlanetNode { this.isMine = false, this.stage, this.colorKey, + this.planetType, bool? isCenter, bool? ranked, }) : isCenter = isCenter ?? isMine, @@ -34,6 +35,9 @@ class PlanetNode { /// 팬덤 `colorKey`(행성 색 팔레트 `planetColors` 의 키). 미제공이면 null. final String? colorKey; + /// 팬덤 `planetType`(생성 때 고른 형태). 미제공이면 null. + final String? planetType; + final String name; /// 갤럭시 전체 순위. HUD 의 `Rank`. @@ -140,6 +144,7 @@ class GalaxySnapshot { isMine: p.isMine, stage: p.stage, colorKey: p.colorKey, + planetType: p.planetType, ranked: p.ranked, isCenter: identical(p, top), ), diff --git a/app/lib/features/galaxy/galaxy_home_screen.dart b/app/lib/features/galaxy/galaxy_home_screen.dart index 57657b6..2a8c4da 100644 --- a/app/lib/features/galaxy/galaxy_home_screen.dart +++ b/app/lib/features/galaxy/galaxy_home_screen.dart @@ -4,6 +4,7 @@ import '../../core/data/planet_visual_state.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_dialog.dart'; import '../../core/ui/app_icons.dart'; +import '../../core/ui/planet_25d.dart' show PlanetArt; import '../notification/notification_screen.dart'; import '../eclipse/eclipse_data.dart'; import '../eclipse/widgets/eclipse_banner.dart'; @@ -29,8 +30,8 @@ class GalaxyHomeScreen extends StatefulWidget { /// 카드 그림 — HOME · 맵과 같이 팬덤 `stage` 로 고른다. Stage0 성운만 성운 그림이고 /// 정식 행성은 시안 행성 그림(`image 8` · 파란 행성 벡터)을 칸마다 번갈아 쓴다. /// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다. - // ponytail: 행성마다 고정된 두 그림 중 하나(id 기준) — 칸 순서가 바뀌어도 같은 그림. - // 탄생 때 고른 모양 · 색 에셋은 최신 Figma 행성 에셋 교체(10/8)에서 연결한다. + // ponytail: 형태가 없는 행성(옛 데이터)만 고정된 두 그림 중 하나(id 기준). 형태가 있으면 + // 카드가 [PlanetArt] 로 생성 때 고른 형태 · 색을 그린다. static String cardImageOf(PlanetNode p, [int _ = 0]) => p.stage == 0 ? 'assets/figma/nebula-card.png' : (p.id.codeUnits.fold(0, (a, c) => a + c).isEven @@ -349,9 +350,10 @@ class _PlanetCard extends StatelessWidget { Positioned.fill( child: Padding( padding: const EdgeInsets.fromLTRB(11, 10, 10, 11), - child: Image.asset( - image, - fit: BoxFit.contain, + child: PlanetArt( + fallback: image, + planetType: planet.stage == 0 ? null : planet.planetType, + colorKey: planet.colorKey, semanticLabel: planet.name, ), ), diff --git a/app/lib/features/galaxy/galaxy_repository_firestore.dart b/app/lib/features/galaxy/galaxy_repository_firestore.dart index df99376..87e2677 100644 --- a/app/lib/features/galaxy/galaxy_repository_firestore.dart +++ b/app/lib/features/galaxy/galaxy_repository_firestore.dart @@ -152,6 +152,7 @@ class FirestoreGalaxyRepository implements GalaxyRepository { _ => null, }, colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, + planetType: data['planetType'] is String ? data['planetType'] as String : null, name: data.strOr('name', id), rank: rank, fpi: data.intOr('fpiScore'), diff --git a/app/lib/features/galaxy/widgets/galaxy_field.dart b/app/lib/features/galaxy/widgets/galaxy_field.dart index e024e7f..4f5ce8d 100644 --- a/app/lib/features/galaxy/widgets/galaxy_field.dart +++ b/app/lib/features/galaxy/widgets/galaxy_field.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/live_planet.dart'; +import '../../../core/ui/planet_25d.dart' show planetShapeImage; import '../../../core/data/planet_visual_state.dart'; import '../galaxy_data.dart'; import '../galaxy_motion.dart'; @@ -133,12 +134,15 @@ class GalaxyField extends StatelessWidget { return radiusOf(p) * depthScaleOf(at); } - /// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다. - static String imageOf(PlanetNode p, int index) => p.isMine - ? 'assets/figma/planet-home.png' - : (index.isEven - ? 'assets/figma/planet-1.png' - : 'assets/figma/planet-2.png'); + /// 행성 일러스트 — 생성 때 고른 형태([planetShapeImage]). 형태가 없는 행성(성운 · 옛 데이터)만 + /// 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다. 색은 [LivePlanet25D] 가 입힌다. + static String imageOf(PlanetNode p, int index) => + planetShapeImage(p.planetType) ?? + (p.isMine + ? 'assets/figma/planet-home.png' + : (index.isEven + ? 'assets/figma/planet-1.png' + : 'assets/figma/planet-2.png')); @override Widget build(BuildContext context) { diff --git a/app/lib/features/home/home_data.dart b/app/lib/features/home/home_data.dart index 9966761..1ca1a36 100644 --- a/app/lib/features/home/home_data.dart +++ b/app/lib/features/home/home_data.dart @@ -333,8 +333,12 @@ class HomeSnapshot { required this.insides, this.items = const [], this.nebulas = const [], + this.weekly, }); + /// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3). 소속 행성이 없으면 null. + final HomeWeekly? weekly; + /// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고 /// 홈 상단에는 활동 POP 을 띄운다. final int popBalance; @@ -364,6 +368,87 @@ class HomeSnapshot { final List items; } +/// HOME 주간 정보 — 발주사 9/9 검토회신 2항 「행성의 주간 순위 · 이번 주 성장 정도 · 이번 주 +/// 기여량 및 기여 비율 · 보유 POP」(HOME 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번). 값의 뜻은 §3(별첨 B): +/// +/// - 순위는 **주간 공식 Snapshot**(`rankingSnapshots`, B-20)의 순위다 — 실시간 순위가 아니다. +/// 변동은 직전 Snapshot 대비(C-43 「순위 동향 · 변동값」). 양수 = 올라감. +/// - 이번 주 성장은 **두 축(Growth POP · sFPI)** 각각의 증분 — 단일 백분율로 합치지 않는다. +/// - 기여 비율은 **행성 주간 성장 총량 대비 본인 비중**. +/// - 보유 POP 은 **일반 POP / 기여 POP** 분리 표기. +/// +/// 「이번 주」의 시작은 마지막 주간 Snapshot 시각(KST 월요일 00:10, Q-A11 임시값)이다. 그 시각의 +/// 행성 누계가 기준선이라 Snapshot 이 없거나 기준선 필드가 없는 옛 Snapshot 이면 그 값은 null. +class HomeWeekly { + const HomeWeekly({ + this.rank, + this.rankChange, + this.weekGrowthPop, + this.weekSfpi, + this.myWeekPop, + this.myShare, + required this.generalPop, + required this.supportPop, + }); + + final int? rank; + final int? rankChange; + final num? weekGrowthPop; + final num? weekSfpi; + /// 이번 주 내가 이 행성 성장에 보탠 Growth POP(`users/{uid}/growthEvents` 합). 못 읽으면 null. + final num? myWeekPop; + + /// 0~1. 행성 주간 성장이 0 이거나 모르면 null. + final double? myShare; + final int generalPop; + final int supportPop; + + /// Snapshot 두 개(최신 · 직전)의 `planets` 와 현재 행성 누계 · 내 이번 주 성장 기여로 만든다. + factory HomeWeekly.from({ + required String fandomId, + required List> snapshots, + required Map fandom, + required num? myWeekPop, + required Map wallet, + }) { + Map? entryOf(Map? snap) { + final planets = snap?['planets']; + if (planets is! List) return null; + for (final p in planets) { + if (p is Map && p['fandomId'] == fandomId) return p.cast(); + } + return null; + } + + final latest = entryOf(snapshots.firstOrNull); + final previous = entryOf(snapshots.length > 1 ? snapshots[1] : null); + int? rankOf(Map? e) => + e?['rank'] is int ? e!['rank'] as int : null; + num? delta(String key) { + final now = fandom[key]; + final base = latest?[key]; + return now is num && base is num ? now - base : null; + } + + final rank = rankOf(latest); + final prev = rankOf(previous); + final weekGrowth = delta('growthPop'); + int intOf(dynamic v) => v is num ? v.toInt() : 0; + return HomeWeekly( + rank: rank, + rankChange: rank != null && prev != null ? prev - rank : null, + weekGrowthPop: weekGrowth, + weekSfpi: delta('sfpi'), + myWeekPop: myWeekPop, + myShare: weekGrowth != null && weekGrowth > 0 && myWeekPop != null + ? (myWeekPop / weekGrowth).clamp(0.0, 1.0).toDouble() + : null, + generalPop: intOf(wallet['activityBalance']), + supportPop: intOf(wallet['supportBalance']), + ); + } +} + /// 아이템(Hero Gift) 상품 한 줄 — 사본 `아이템 구입`(`252:160233`) 카드. /// /// 구매하면 구매자에게 기여 POP, 팬덤 Drop Pool 에 같은 수량의 일반 POP 이 생긴다 diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index 991f1f3..e328d1a 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -184,6 +184,17 @@ class FirestoreHomeRepository implements HomeRepository { .limit(20) .get(); + // HOME 주간 정보(A-37) — 주간 공식 Snapshot 최신 · 직전 두 개(순위 · 변동 · 성장 기준선). + final snapshotsF = fandomId.isEmpty + ? null + : db + .collection('rankingSnapshots') + .orderBy('createdAt', descending: true) + .limit(2) + .get() + .then((q) => [for (final d in q.docs) d.data()]) + .catchError((Object _) => >[]); + final wallet = (await walletF).data() ?? const {}; final fandom = fandomF == null ? const {} @@ -226,7 +237,23 @@ class FirestoreHomeRepository implements HomeRepository { final progressById = {for (final doc in progress) doc.id: doc.data()}; + HomeWeekly? weekly; + if (snapshotsF != null) { + final snapshots = await snapshotsF; + final since = snapshots.firstOrNull?['createdAt'] is Timestamp + ? (snapshots.first['createdAt'] as Timestamp).toDate() + : kstWeekStart(DateTime.now()); + weekly = HomeWeekly.from( + fandomId: fandomId, + snapshots: snapshots, + fandom: fandom, + myWeekPop: await _myWeekGrowth(uid, fandomId, since), + wallet: wallet, + ); + } + return HomeSnapshot( + weekly: weekly, popBalance: wallet.intOr('activityBalance'), fandomId: fandomId, liveTicker: fandom.strOr('liveTicker').isEmpty @@ -300,6 +327,27 @@ class FirestoreHomeRepository implements HomeRepository { ); } + /// 이번 주 내가 [fandomId] 행성 성장에 보탠 Growth POP — 원장과 같은 트랜잭션에서 쓰는 + /// `users/{uid}/growthEvents`(역분개는 음수 행)의 [since] 이후 합. 읽지 못하면 null. + // ponytail: 한 주치 성장 행을 전부 읽어 합한다. 한 사용자가 주에 수천 건을 넘기면 서버 집계 필드로 옮긴다. + Future _myWeekGrowth(String uid, String fandomId, DateTime since) async { + try { + final q = await _backend.db + .collection('users/$uid/growthEvents') + .where('createdAt', isGreaterThanOrEqualTo: Timestamp.fromDate(since)) + .get(); + num sum = 0; + for (final d in q.docs) { + final data = d.data(); + final amount = data['amount']; + if (data['fandomIdAtPayout'] == fandomId && amount is num) sum += amount; + } + return sum; + } catch (_) { + return null; + } + } + Future _growth( Map fandom, String fandomId, @@ -529,6 +577,13 @@ String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc()) .toIso8601String() .substring(0, 10); +/// 이번 주 시작 — KST 월요일 00:00(서버 `weekKeyOf` 와 같은 주 경계). 주간 Snapshot 이 아직 없을 때만 쓴다. +DateTime kstWeekStart(DateTime at) { + final kst = at.toUtc().add(const Duration(hours: 9)); + final monday = DateTime.utc(kst.year, kst.month, kst.day - (kst.weekday - 1)); + return monday.subtract(const Duration(hours: 9)); +} + /// HOME TODAY'S QUEST 의 관리자 퀘스트 — `authorUid` 가 없는 것만, `createdAt` 최신순 /// (설명 프레임 `2097:16397` 「관리자 퀘스트 - 최신순으로 최대 3개 노출」). List adminQuestsLatest( diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index a3d6741..7ca6c25 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -25,6 +25,7 @@ import '../eclipse/widgets/live_proof.dart'; import 'home_data.dart'; import 'nebula_create_screen.dart'; import 'planet_create_screen.dart'; +import 'widgets/home_weekly.dart'; import 'widgets/briefing_list.dart'; import 'widgets/home_hero.dart'; import 'widgets/item_sheet.dart'; @@ -619,6 +620,19 @@ class _HomeBody extends StatelessWidget { // 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와 // 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · // 인기 K-INSIDE. + // HOME 주간 정보(A-37 · 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번) — 🟡 자리 임시(Q-A16). + if (data.weekly case final weekly?) ...[ + Padding( + padding: _h, + child: HomeSectionHeader(title: context.l10n.homeWeeklyTitle), + ), + const SizedBox(height: _headerGap), + Padding( + padding: _h, + child: HomeWeeklyCard(weekly: weekly), + ), + const SizedBox(height: _sectionGap), + ], Padding( padding: _h, child: HomeSectionHeader( diff --git a/app/lib/features/home/planet_create_screen.dart b/app/lib/features/home/planet_create_screen.dart index 8630842..e20c9d2 100644 --- a/app/lib/features/home/planet_create_screen.dart +++ b/app/lib/features/home/planet_create_screen.dart @@ -4,7 +4,7 @@ import '../../core/data/backend.dart' show BackendException; import '../../core/theme/tokens.dart'; import '../../core/ui/app_dialog.dart'; import '../../core/ui/app_icons.dart'; -import '../../core/ui/planet_25d.dart' show planetColors; +import '../../core/ui/planet_25d.dart' show planetColors, planetShapeImage; import 'home_data.dart'; import '../../core/i18n/app_locale.dart'; @@ -15,7 +15,7 @@ import '../../core/i18n/app_locale.dart'; /// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과 /// 직접 색 고르기(스포이트) 칩은 두지 않는다. /// -/// 형태 칸은 사본에서도 모두 같은 자리표시 이미지다(개별 형태 에셋은 S08 확인 대상). +/// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14). class PlanetCreateScreen extends StatefulWidget { const PlanetCreateScreen({ super.key, @@ -68,10 +68,6 @@ class _PlanetCreateScreenState extends State { }); } - static const _placeholder = AssetImage( - 'assets/figma/planet-shape-placeholder.png', - ); - static const _heading = TextStyle( fontFamily: AppFonts.body, fontSize: 18, @@ -80,6 +76,10 @@ class _PlanetCreateScreenState extends State { color: AppColors.onCosmic, ); + /// 고른 형태 그림 — HOME · GALAXY · 행성 상세 · MY 가 그리는 그림과 같은 표([planetShapeImage]). + Image get _shapeImage => + Image.asset(planetShapeImage(PlanetCreateScreen.shapes[_shape])!); + Widget _preview() => Center( child: SizedBox.square( dimension: 212, @@ -89,9 +89,9 @@ class _PlanetCreateScreenState extends State { _colors[_color].$2, BlendMode.modulate, ), - child: const Image(image: _placeholder), + child: _shapeImage, ) - : const Image(image: _placeholder), + : _shapeImage, ), ); @@ -112,14 +112,10 @@ class _PlanetCreateScreenState extends State { ? Border.all(color: AppColors.cosmicAccent, width: 2) : null, ), - // 사본 칸 그림(렌더에서 4 안쪽을 자른 것). 6번 칸은 사본에서 버튼에 가려 - // 모양이 보이지 않아 자리표시를 둔다. - padding: i < 5 ? const EdgeInsets.all(4) : null, - child: Image.asset( - i < 5 - ? 'assets/figma/planet-shape-${i + 1}.png' - : 'assets/figma/planet-shape-placeholder.png', - ), + // 사본 칸 그림 — 행성 형태 시트에서 잘라 낸 투명 그림([planetShapeImage]). 6번 칸은 + // 사본에서 버튼에 가려 모양이 보이지 않아 자리표시를 둔다(Q-A14). + padding: const EdgeInsets.all(4), + child: Image.asset(planetShapeImage(PlanetCreateScreen.shapes[i])!), ), ), ); diff --git a/app/lib/features/home/widgets/home_weekly.dart b/app/lib/features/home/widgets/home_weekly.dart new file mode 100644 index 0000000..90736cf --- /dev/null +++ b/app/lib/features/home/widgets/home_weekly.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../../../core/i18n/app_locale.dart'; +import '../../../core/theme/tokens.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; + +/// HOME 주간 정보 — 발주사 9/9 검토회신 2항 · HOME 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번(A-37). +/// +/// 🟡 **배치는 임시다.** 기준 화면 「REFINE v5A · HOME · Default」 를 아직 실측하지 못해(당사 사본에 +/// 없고 원본 파일은 로그인 필요) 정보 위계 v1.0 §7 「프레임 실측과 무관하게 반드시 포함」에 따라 +/// 값만 먼저 싣는다. 자리 · 모양은 해석 확인 요청 영역A Q-A16 회신 또는 실측 후 맞춘다. +/// 값의 뜻은 [HomeWeekly] — 모르는 값은 0 으로 채우지 않고 「—」. +class HomeWeeklyCard extends StatelessWidget { + const HomeWeeklyCard({super.key, required this.weekly}); + + final HomeWeekly weekly; + + static const _label = TextStyle( + fontFamily: AppFonts.body, + fontSize: 13, + height: 18 / 13, + color: AppColors.onCosmicVariant, + ); + static const _value = TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 18 / 14, + color: AppColors.onCosmic, + ); + + static String _signed(num v) => '${v >= 0 ? '+' : '−'}${formatPop(v.abs().round())}'; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final w = weekly; + final change = w.rankChange; + Widget row(String label, Widget value) => Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpace.s2), + child: Row( + children: [ + Expanded(child: Text(label, style: _label)), + value, + ], + ), + ); + Text text(String s) => Text(s, style: _value); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s4, + vertical: AppSpace.s2, + ), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.cosmicOutline), + ), + child: Column( + children: [ + row( + l10n.homeWeeklyRank, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + text(w.rank == null ? '—' : l10n.homeWeeklyRankValue('${w.rank}')), + if (change != null && change != 0) ...[ + const SizedBox(width: AppSpace.s1), + Text( + '${change > 0 ? '▲' : '▼'}${change.abs()}', + style: _value.copyWith( + fontSize: 12, + color: change > 0 + ? AppColors.cosmicAccent + : AppColors.cosmicBlue, + ), + ), + ], + ], + ), + ), + // 두 축 각각 — 단일 백분율로 합치지 않는다(§3 4번). + row( + l10n.homeWeeklyGrowth, + text( + '${w.weekGrowthPop == null ? '—' : _signed(w.weekGrowthPop!)} POP · ' + '${w.weekSfpi == null ? '—' : _signed(w.weekSfpi!)} sFPI', + ), + ), + row( + l10n.homeWeeklyMine, + text( + '${w.myWeekPop == null ? '—' : formatPop(w.myWeekPop!.round())} POP' + '${w.myShare == null ? '' : ' · ${(w.myShare! * 100).toStringAsFixed(1)}%'}', + ), + ), + row( + l10n.homeWeeklyBalance, + text( + '${l10n.popKindGeneral} ${formatPop(w.generalPop)} · ' + '${l10n.popKindContribution} ${formatPop(w.supportPop)}', + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/my/my_data.dart b/app/lib/features/my/my_data.dart index bc562e8..813363c 100644 --- a/app/lib/features/my/my_data.dart +++ b/app/lib/features/my/my_data.dart @@ -264,8 +264,14 @@ class PlanetTier { this.fandomId = '', this.passportTier = PassportTier.silver, this.joined = true, + this.planetType, + this.colorKey, }); + /// 팬덤 문서의 생성 때 고른 형태 · 색(`fn-planet-create`). 성운이면 null. + final String? planetType; + final String? colorKey; + final String fandomName; final int contributedPop; diff --git a/app/lib/features/my/my_repository_firestore.dart b/app/lib/features/my/my_repository_firestore.dart index 438ee50..d90b42f 100644 --- a/app/lib/features/my/my_repository_firestore.dart +++ b/app/lib/features/my/my_repository_firestore.dart @@ -93,6 +93,8 @@ class FirestoreMyRepository extends SampleMyRepository { isPrimary: id == primaryId, passportTier: PassportTier.parse(passport.strOr('tier')), joined: joined, + planetType: fandom['planetType'] is String ? fandom['planetType'] as String : null, + colorKey: fandom['colorKey'] is String ? fandom['colorKey'] as String : null, ); })); final primaryTier = tiers.where((t) => t.isPrimary).firstOrNull?.passportTier ?? PassportTier.silver; diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart index 69f88e6..569e85a 100644 --- a/app/lib/features/my/my_screen.dart +++ b/app/lib/features/my/my_screen.dart @@ -6,6 +6,7 @@ import '../../core/i18n/app_locale.dart'; import '../../core/push/push_messaging.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_icons.dart'; +import '../../core/ui/planet_25d.dart' show PlanetArt; import '../auth/auth.dart'; import '../auth/auth_repository.dart'; import '../campaign/campaign_data.dart' show CampaignRepository; @@ -260,12 +261,15 @@ class _MyScreenState extends State { ), ), child: i < _planets.length - ? Image.asset( - GalaxyHomeScreen.cardImageOf( + ? PlanetArt( + fallback: GalaxyHomeScreen.cardImageOf( _planets[i], i, ), - fit: BoxFit.contain, + planetType: _planets[i].stage == 0 + ? null + : _planets[i].planetType, + colorKey: _planets[i].colorKey, semanticLabel: _planets[i].name, ) : null, diff --git a/app/lib/features/my/tier_screens.dart b/app/lib/features/my/tier_screens.dart index ba49048..48ffc66 100644 --- a/app/lib/features/my/tier_screens.dart +++ b/app/lib/features/my/tier_screens.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; +import '../../core/ui/planet_25d.dart' show PlanetArt; import '../home/home_screen.dart' show formatPop; import '../planet/planet_data.dart' show PlanetRepositoryFactory; import '../planet/planet_history_screen.dart'; @@ -211,8 +212,10 @@ class _PlanetCard extends StatelessWidget { width: 60, child: Column( children: [ - Image.asset( - 'assets/figma/my-planet-1.png', + PlanetArt( + fallback: 'assets/figma/my-planet-1.png', + planetType: planet.planetType, + colorKey: planet.colorKey, width: 60, height: 60, ), diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 539412c..033a66f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -867,5 +867,11 @@ "onbPermContinue": "Continue", "coachZap": "Tap to send POP to our planet", "coachMy": "Your records are here", - "settingsWithdrawLinked": "Please unlink your connected accounts before deleting your account." + "settingsWithdrawLinked": "Please unlink your connected accounts before deleting your account.", + "homeWeeklyTitle": "Our planet this week", + "homeWeeklyRank": "Weekly rank", + "homeWeeklyRankValue": "#{rank}", + "homeWeeklyGrowth": "Growth this week", + "homeWeeklyMine": "My contribution this week", + "homeWeeklyBalance": "My POP" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index dd2a140..f40f4ec 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -867,5 +867,11 @@ "onbPermContinue": "Continuar", "coachZap": "Toca para enviar POP a nuestro planeta", "coachMy": "Aquí puedes ver tus registros", - "settingsWithdrawLinked": "Desvincula tus cuentas conectadas antes de eliminar tu cuenta." + "settingsWithdrawLinked": "Desvincula tus cuentas conectadas antes de eliminar tu cuenta.", + "homeWeeklyTitle": "Nuestro planeta esta semana", + "homeWeeklyRank": "Ranking semanal", + "homeWeeklyRankValue": "#{rank}", + "homeWeeklyGrowth": "Crecimiento semanal", + "homeWeeklyMine": "Mi aporte semanal", + "homeWeeklyBalance": "Mis POP" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index b156ddd..a74fac4 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -867,5 +867,11 @@ "onbPermContinue": "続ける", "coachZap": "タップしてPOPを私たちの惑星に送ろう", "coachMy": "自分の記録はここで見られます", - "settingsWithdrawLinked": "連携中のアカウントを解除してから退会してください。" + "settingsWithdrawLinked": "連携中のアカウントを解除してから退会してください。", + "homeWeeklyTitle": "今週の私たちの惑星", + "homeWeeklyRank": "週間順位", + "homeWeeklyRankValue": "{rank}位", + "homeWeeklyGrowth": "今週の成長", + "homeWeeklyMine": "今週の私の貢献", + "homeWeeklyBalance": "保有POP" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index cb54c99..ee6fe93 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1814,5 +1814,18 @@ "onbPermContinue": "계속", "coachZap": "눌러서 POP 을 우리 행성에 보내요", "coachMy": "내 기록은 여기서 볼 수 있어요", - "settingsWithdrawLinked": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요." + "settingsWithdrawLinked": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요.", + "homeWeeklyTitle": "이번 주 우리 행성", + "homeWeeklyRank": "주간 순위", + "homeWeeklyRankValue": "{rank}위", + "@homeWeeklyRankValue": { + "placeholders": { + "rank": { + "type": "String" + } + } + }, + "homeWeeklyGrowth": "이번 주 성장", + "homeWeeklyMine": "이번 주 내 기여", + "homeWeeklyBalance": "보유 POP" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index cb035a6..696d052 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -5323,6 +5323,42 @@ abstract class AppLocalizations { /// In ko, this message translates to: /// **'연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'** String get settingsWithdrawLinked; + + /// No description provided for @homeWeeklyTitle. + /// + /// In ko, this message translates to: + /// **'이번 주 우리 행성'** + String get homeWeeklyTitle; + + /// No description provided for @homeWeeklyRank. + /// + /// In ko, this message translates to: + /// **'주간 순위'** + String get homeWeeklyRank; + + /// No description provided for @homeWeeklyRankValue. + /// + /// In ko, this message translates to: + /// **'{rank}위'** + String homeWeeklyRankValue(String rank); + + /// No description provided for @homeWeeklyGrowth. + /// + /// In ko, this message translates to: + /// **'이번 주 성장'** + String get homeWeeklyGrowth; + + /// No description provided for @homeWeeklyMine. + /// + /// In ko, this message translates to: + /// **'이번 주 내 기여'** + String get homeWeeklyMine; + + /// No description provided for @homeWeeklyBalance. + /// + /// In ko, this message translates to: + /// **'보유 POP'** + String get homeWeeklyBalance; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 65a29bf..f213c50 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -3029,4 +3029,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsWithdrawLinked => 'Please unlink your connected accounts before deleting your account.'; + + @override + String get homeWeeklyTitle => 'Our planet this week'; + + @override + String get homeWeeklyRank => 'Weekly rank'; + + @override + String homeWeeklyRankValue(String rank) { + return '#$rank'; + } + + @override + String get homeWeeklyGrowth => 'Growth this week'; + + @override + String get homeWeeklyMine => 'My contribution this week'; + + @override + String get homeWeeklyBalance => 'My POP'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index c42c4a0..d8e6748 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -3046,4 +3046,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsWithdrawLinked => 'Desvincula tus cuentas conectadas antes de eliminar tu cuenta.'; + + @override + String get homeWeeklyTitle => 'Nuestro planeta esta semana'; + + @override + String get homeWeeklyRank => 'Ranking semanal'; + + @override + String homeWeeklyRankValue(String rank) { + return '#$rank'; + } + + @override + String get homeWeeklyGrowth => 'Crecimiento semanal'; + + @override + String get homeWeeklyMine => 'Mi aporte semanal'; + + @override + String get homeWeeklyBalance => 'Mis POP'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 62d30e2..a3cf449 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -2924,4 +2924,24 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsWithdrawLinked => '連携中のアカウントを解除してから退会してください。'; + + @override + String get homeWeeklyTitle => '今週の私たちの惑星'; + + @override + String get homeWeeklyRank => '週間順位'; + + @override + String homeWeeklyRankValue(String rank) { + return '$rank位'; + } + + @override + String get homeWeeklyGrowth => '今週の成長'; + + @override + String get homeWeeklyMine => '今週の私の貢献'; + + @override + String get homeWeeklyBalance => '保有POP'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index fd077ba..047365e 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -2928,4 +2928,24 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsWithdrawLinked => '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'; + + @override + String get homeWeeklyTitle => '이번 주 우리 행성'; + + @override + String get homeWeeklyRank => '주간 순위'; + + @override + String homeWeeklyRankValue(String rank) { + return '$rank위'; + } + + @override + String get homeWeeklyGrowth => '이번 주 성장'; + + @override + String get homeWeeklyMine => '이번 주 내 기여'; + + @override + String get homeWeeklyBalance => '보유 POP'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 6e8dfbf..c1d2e80 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -2920,4 +2920,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsWithdrawLinked => '请先解除关联账号后再注销账号。'; + + @override + String get homeWeeklyTitle => '本周我们的星球'; + + @override + String get homeWeeklyRank => '周排名'; + + @override + String homeWeeklyRankValue(String rank) { + return '第$rank名'; + } + + @override + String get homeWeeklyGrowth => '本周成长'; + + @override + String get homeWeeklyMine => '本周我的贡献'; + + @override + String get homeWeeklyBalance => '持有POP'; } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index f5d4d8c..45fe622 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -867,5 +867,11 @@ "onbPermContinue": "继续", "coachZap": "点击将POP发送到我们的星球", "coachMy": "我的记录可以在这里查看", - "settingsWithdrawLinked": "请先解除关联账号后再注销账号。" + "settingsWithdrawLinked": "请先解除关联账号后再注销账号。", + "homeWeeklyTitle": "本周我们的星球", + "homeWeeklyRank": "周排名", + "homeWeeklyRankValue": "第{rank}名", + "homeWeeklyGrowth": "本周成长", + "homeWeeklyMine": "本周我的贡献", + "homeWeeklyBalance": "持有POP" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index e63f2fb..3fea589 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -970,6 +970,24 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get homeRetryLater => _remote['homeRetryLater'] ?? _base.homeRetryLater; + @override + String get homeWeeklyBalance => _remote['homeWeeklyBalance'] ?? _base.homeWeeklyBalance; + + @override + String get homeWeeklyGrowth => _remote['homeWeeklyGrowth'] ?? _base.homeWeeklyGrowth; + + @override + String get homeWeeklyMine => _remote['homeWeeklyMine'] ?? _base.homeWeeklyMine; + + @override + String get homeWeeklyRank => _remote['homeWeeklyRank'] ?? _base.homeWeeklyRank; + + @override + String homeWeeklyRankValue(String rank) => _fill('homeWeeklyRankValue', {'rank': rank}) ?? _base.homeWeeklyRankValue(rank); + + @override + String get homeWeeklyTitle => _remote['homeWeeklyTitle'] ?? _base.homeWeeklyTitle; + @override String get homeWhereBriefing => _remote['homeWhereBriefing'] ?? _base.homeWhereBriefing; diff --git a/app/test/home_weekly_test.dart b/app/test/home_weekly_test.dart new file mode 100644 index 0000000..162a35e --- /dev/null +++ b/app/test/home_weekly_test.dart @@ -0,0 +1,65 @@ +import 'package:fancake/features/home/home_data.dart'; +import 'package:fancake/features/home/home_repository_firestore.dart' show kstWeekStart; +import 'package:flutter_test/flutter_test.dart'; + +/// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3). +void main() { + Map snap(int rank, {num? growthPop, num? sfpi}) => { + 'planets': [ + {'fandomId': 'other', 'rank': 1}, + { + 'fandomId': 'f', + 'rank': rank, + 'growthPop': ?growthPop, + 'sfpi': ?sfpi, + }, + ], + }; + + test('순위는 주간 Snapshot · 변동은 직전 대비 · 성장은 두 축 증분 · 비율은 행성 주간 총량 대비', () { + final w = HomeWeekly.from( + fandomId: 'f', + snapshots: [snap(3, growthPop: 1000, sfpi: 40), snap(5)], + fandom: {'growthPop': 1500, 'sfpi': 52}, + myWeekPop: 125, + wallet: {'activityBalance': 700, 'supportBalance': 30}, + ); + expect(w.rank, 3); + expect(w.rankChange, 2, reason: '5위 → 3위 = 두 계단 오름'); + expect(w.weekGrowthPop, 500); + expect(w.weekSfpi, 12); + expect(w.myShare, 0.25); + expect((w.generalPop, w.supportPop), (700, 30)); + }); + + test('기준선 · 직전 Snapshot · 내 기여를 모르면 0 으로 채우지 않는다', () { + final w = HomeWeekly.from( + fandomId: 'f', + snapshots: [snap(4)], + fandom: {'growthPop': 1500}, + myWeekPop: null, + wallet: const {}, + ); + expect(w.rank, 4); + expect(w.rankChange, isNull); + expect(w.weekGrowthPop, isNull); + expect(w.myShare, isNull); + final none = HomeWeekly.from( + fandomId: 'f', snapshots: const [], fandom: const {}, myWeekPop: 0, wallet: const {}, + ); + expect(none.rank, isNull); + }); + + test('이번 주 시작은 KST 월요일 00:00', () { + // 2026-10-07(수) 15:00 KST → 2026-10-05(월) 00:00 KST = 10-04 15:00 UTC. + expect( + kstWeekStart(DateTime.parse('2026-10-07T06:00:00Z')), + DateTime.parse('2026-10-04T15:00:00Z'), + ); + // 월요일 08:59 KST(= 일요일 23:59 UTC)도 그 주다. + expect( + kstWeekStart(DateTime.parse('2026-10-04T23:59:00Z')), + DateTime.parse('2026-10-04T15:00:00Z'), + ); + }); +} diff --git a/app/test/planet_fx_test.dart b/app/test/planet_fx_test.dart index 96c12f5..ff1b340 100644 --- a/app/test/planet_fx_test.dart +++ b/app/test/planet_fx_test.dart @@ -17,6 +17,25 @@ void main() { expect(PlanetVisualState.fromDocument({'stage': 2}).evolutionImminent, isFalse); }); + test('생성 때 고른 형태가 HOME · GALAXY · 상세 · MY 그림을 정한다 (A-08 · Figma 344:214421)', () { + for (var i = 1; i <= 5; i++) { + expect(planetShapeImage('shape$i'), 'assets/figma/planet-shape-$i.png'); + } + expect(planetShapeImage('shape6'), 'assets/figma/planet-shape-placeholder.png'); + expect(planetShapeImage(null), isNull, reason: '성운 · 옛 행성은 화면이 준 그림'); + expect(planetShapeImage('shape7'), isNull); + // 생성 화면이 보내는 키와 같은 표여야 한다. + for (final k in PlanetCreateScreen.shapes) { + expect(planetShapeImage(k), isNotNull); + } + final state = PlanetVisualState.fromDocument({'planetType': 'shape3', 'colorKey': 'gold'}); + expect(state.planetType, 'shape3'); + final node = const PlanetNode( + id: 'f', name: 'F', rank: 1, fpi: 0, members: 0, online: 0, planetType: 'shape2', + ); + expect(GalaxyField.imageOf(node, 0), 'assets/figma/planet-shape-2.png'); + }); + test('컬러는 마스터(config/planetColors)가 정한다 — 추가 색은 그리고 숨긴 색은 고르지 못한다 (10/3 #6)', () { final master = planetColorsFrom({'colors': [ {'key': 'gold', 'hex': '#FFC500', 'visible': false},