생성 때 고른 행성 형태·색 표시 · HOME 주간 정보

This commit is contained in:
Sn-Kinos
2026-10-05 23:21:44 +09:00
parent 55424bd4a2
commit c8c1a1ae67
40 changed files with 688 additions and 40 deletions
+5
View File
@@ -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) {
+85
View File
@@ -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>(
+14
View File
@@ -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(
+12 -16
View File
@@ -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)}',
),
),
],
),
);
}
}
+6
View File
@@ -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;
+7 -3
View File
@@ -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,
+5 -2
View File
@@ -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,
),