생성 때 고른 행성 형태·색 표시 · HOME 주간 정보
This commit is contained in:
@@ -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)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user