생성 때 고른 행성 형태·색 표시 · HOME 주간 정보
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 74 KiB |
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Record<string, unknown>>).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 });
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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<int>(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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ItemProduct> 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<Map<String, dynamic>> snapshots,
|
||||
required Map<String, dynamic> fandom,
|
||||
required num? myWeekPop,
|
||||
required Map<String, dynamic> wallet,
|
||||
}) {
|
||||
Map<String, dynamic>? entryOf(Map<String, dynamic>? 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<String, dynamic>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final latest = entryOf(snapshots.firstOrNull);
|
||||
final previous = entryOf(snapshots.length > 1 ? snapshots[1] : null);
|
||||
int? rankOf(Map<String, dynamic>? 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 이 생긴다
|
||||
|
||||
@@ -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 _) => <Map<String, dynamic>>[]);
|
||||
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
final fandom = fandomF == null
|
||||
? const <String, dynamic>{}
|
||||
@@ -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<num?> _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<PlanetGrowth> _growth(
|
||||
Map<String, dynamic> 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<T> adminQuestsLatest<T>(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PlanetCreateScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
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<PlanetCreateScreen> {
|
||||
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<PlanetCreateScreen> {
|
||||
_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<PlanetCreateScreen> {
|
||||
? 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])!),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MyScreen> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -867,5 +867,11 @@
|
||||
"onbPermContinue": "続ける",
|
||||
"coachZap": "タップしてPOPを私たちの惑星に送ろう",
|
||||
"coachMy": "自分の記録はここで見られます",
|
||||
"settingsWithdrawLinked": "連携中のアカウントを解除してから退会してください。"
|
||||
"settingsWithdrawLinked": "連携中のアカウントを解除してから退会してください。",
|
||||
"homeWeeklyTitle": "今週の私たちの惑星",
|
||||
"homeWeeklyRank": "週間順位",
|
||||
"homeWeeklyRankValue": "{rank}位",
|
||||
"homeWeeklyGrowth": "今週の成長",
|
||||
"homeWeeklyMine": "今週の私の貢献",
|
||||
"homeWeeklyBalance": "保有POP"
|
||||
}
|
||||
|
||||
@@ -1814,5 +1814,18 @@
|
||||
"onbPermContinue": "계속",
|
||||
"coachZap": "눌러서 POP 을 우리 행성에 보내요",
|
||||
"coachMy": "내 기록은 여기서 볼 수 있어요",
|
||||
"settingsWithdrawLinked": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요."
|
||||
"settingsWithdrawLinked": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요.",
|
||||
"homeWeeklyTitle": "이번 주 우리 행성",
|
||||
"homeWeeklyRank": "주간 순위",
|
||||
"homeWeeklyRankValue": "{rank}위",
|
||||
"@homeWeeklyRankValue": {
|
||||
"placeholders": {
|
||||
"rank": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeWeeklyGrowth": "이번 주 성장",
|
||||
"homeWeeklyMine": "이번 주 내 기여",
|
||||
"homeWeeklyBalance": "보유 POP"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -867,5 +867,11 @@
|
||||
"onbPermContinue": "继续",
|
||||
"coachZap": "点击将POP发送到我们的星球",
|
||||
"coachMy": "我的记录可以在这里查看",
|
||||
"settingsWithdrawLinked": "请先解除关联账号后再注销账号。"
|
||||
"settingsWithdrawLinked": "请先解除关联账号后再注销账号。",
|
||||
"homeWeeklyTitle": "本周我们的星球",
|
||||
"homeWeeklyRank": "周排名",
|
||||
"homeWeeklyRankValue": "第{rank}名",
|
||||
"homeWeeklyGrowth": "本周成长",
|
||||
"homeWeeklyMine": "本周我的贡献",
|
||||
"homeWeeklyBalance": "持有POP"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, dynamic> 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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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},
|
||||
|
||||