From be623d2e46346420b12afbf35da4dec5e3cb58ea Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 18:07:44 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B1=EC=9A=B4=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EC=97=AC=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=EC=97=90=20=EC=8B=A4=EC=A0=9C=20=EA=B8=B0=EC=97=AC?= =?UTF-8?q?=EC=9E=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/planet/fpi_ranking_screen.dart | 4 +- .../planet/hero_participation_sheet.dart | 118 +++++++--- app/lib/features/planet/moderation.dart | 68 ++++-- app/lib/features/planet/planet_data.dart | 86 +++++-- .../features/planet/planet_detail_screen.dart | 209 +++++++++++------- .../planet/planet_history_screen.dart | 48 ++-- .../planet/planet_repository_firestore.dart | 181 ++++++++++----- .../planet/widgets/planet_detail_hero.dart | 8 +- app/test/f3_screens_test.dart | 21 ++ 9 files changed, 514 insertions(+), 229 deletions(-) diff --git a/app/lib/features/planet/fpi_ranking_screen.dart b/app/lib/features/planet/fpi_ranking_screen.dart index 270b771..8951f21 100644 --- a/app/lib/features/planet/fpi_ranking_screen.dart +++ b/app/lib/features/planet/fpi_ranking_screen.dart @@ -428,7 +428,9 @@ class _Podium extends StatelessWidget { _avatar(i + 3, 80), const SizedBox(height: 8), Text( - rows[i].isMine ? context.l10n.rankMeSuffix(rows[i].name) : rows[i].name, + rows[i].isMine + ? context.l10n.rankMeSuffix(rows[i].name) + : rows[i].name, maxLines: 1, softWrap: false, overflow: TextOverflow.visible, diff --git a/app/lib/features/planet/hero_participation_sheet.dart b/app/lib/features/planet/hero_participation_sheet.dart index ba01bfc..3ac1ddb 100644 --- a/app/lib/features/planet/hero_participation_sheet.dart +++ b/app/lib/features/planet/hero_participation_sheet.dart @@ -11,8 +11,13 @@ import '../../core/i18n/app_locale.dart'; /// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다. class HeroParticipationSheet extends StatefulWidget { - const HeroParticipationSheet({super.key, required this.fandomId, - required this.watch, required this.repository, this.accelerometer}); + const HeroParticipationSheet({ + super.key, + required this.fandomId, + required this.watch, + required this.repository, + this.accelerometer, + }); final String fandomId; final PlanetWatch watch; final PlanetRepository repository; @@ -39,10 +44,17 @@ class _HeroParticipationSheetState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((event) { - final magnitude = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z); + _sensor = (widget.accelerometer ?? accelerometerEventStream()).listen(( + event, + ) { + final magnitude = math.sqrt( + event.x * event.x + event.y * event.y + event.z * event.z, + ); final now = DateTime.now(); - if (!magnitude.isFinite || magnitude < 18 || now.difference(_lastShake).inMilliseconds < 220) return; + if (!magnitude.isFinite || + magnitude < 18 || + now.difference(_lastShake).inMilliseconds < 220) + return; _lastShake = now; if (!_failed) _claim(); }, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다. @@ -62,21 +74,39 @@ class _HeroParticipationSheetState extends State String? get _next { final state = _state; - if (_accessDenied || state?.isHeroTime != true || state?.heroEndsAt?.isAfter(DateTime.now()) != true) return null; - final ids = (state?.heroEventIds ?? {}).where((id) => !_received.containsKey(id) && !_unavailable.contains(id)).toList()..sort(); + if (_accessDenied || + state?.isHeroTime != true || + state?.heroEndsAt?.isAfter(DateTime.now()) != true) + return null; + final ids = + (state?.heroEventIds ?? {}) + .where( + (id) => !_received.containsKey(id) && !_unavailable.contains(id), + ) + .toList() + ..sort(); return ids.firstOrNull; } Future _claim() async { - if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true) return; + if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true) + return; // 응답 유실 재확인은 이벤트 종료 뒤에도 같은 멱등키로 요청한다. final id = _pendingId ?? _next; if (id == null) return; - setState(() { _sending = true; _failed = false; _notice = null; _pendingId = id; }); + setState(() { + _sending = true; + _failed = false; + _notice = null; + _pendingId = id; + }); try { final receipt = await widget.repository.claimHeroDrop(id); if (!mounted) return; - setState(() { _received[id] = receipt; _pendingId = null; }); + setState(() { + _received[id] = receipt; + _pendingId = null; + }); if (_foreground) HapticFeedback.lightImpact(); } catch (error) { if (!mounted) return; @@ -109,28 +139,54 @@ class _HeroParticipationSheetState extends State _state = state; final available = _next != null; final total = _received.values.fold(0, (sum, r) => sum + r.amount); - return SafeArea(child: Padding( - padding: const EdgeInsets.all(24), - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('HERO TIME · ZAP+', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), - Text(context.l10n.heroGiftHowTo), - Text(context.l10n.heroGiftPopNote), - const SizedBox(height: 16), - if (_received.isNotEmpty) - Semantics(liveRegion: true, child: Text(context.l10n.heroGiftReceived('$total'), style: Theme.of(context).textTheme.titleMedium)), - if (_notice case final notice?) Semantics(liveRegion: true, child: Text(notice)), - if (_failed) Text(context.l10n.heroGiftUnknown), - if (!available && !_sending && !_failed && _notice == null) - Text(_received.isEmpty ? context.l10n.heroGiftNone : context.l10n.heroGiftConfirmed), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _sending || (!available && !_failed) ? null : _claim, - icon: const Icon(Icons.auto_awesome), - label: Text(_sending ? context.l10n.heroGiftChecking : _failed ? context.l10n.heroGiftRetry : context.l10n.heroGiftTap), + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'HERO TIME · ZAP+', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text(context.l10n.heroGiftHowTo), + Text(context.l10n.heroGiftPopNote), + const SizedBox(height: 16), + if (_received.isNotEmpty) + Semantics( + liveRegion: true, + child: Text( + context.l10n.heroGiftReceived('$total'), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (_notice case final notice?) + Semantics(liveRegion: true, child: Text(notice)), + if (_failed) Text(context.l10n.heroGiftUnknown), + if (!available && !_sending && !_failed && _notice == null) + Text( + _received.isEmpty + ? context.l10n.heroGiftNone + : context.l10n.heroGiftConfirmed, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _sending || (!available && !_failed) ? null : _claim, + icon: const Icon(Icons.auto_awesome), + label: Text( + _sending + ? context.l10n.heroGiftChecking + : _failed + ? context.l10n.heroGiftRetry + : context.l10n.heroGiftTap, + ), + ), + ], ), - ]), - )); + ), + ); }, ); } diff --git a/app/lib/features/planet/moderation.dart b/app/lib/features/planet/moderation.dart index 8ff68a6..fe7ae5c 100644 --- a/app/lib/features/planet/moderation.dart +++ b/app/lib/features/planet/moderation.dart @@ -51,11 +51,7 @@ class ModerationTarget { /// 신고하기 화면. class ReportScreen extends StatefulWidget { - const ReportScreen({ - super.key, - required this.target, - this.reasons, - }); + const ReportScreen({super.key, required this.target, this.reasons}); final ModerationTarget target; final List? reasons; @@ -67,15 +63,18 @@ class ReportScreen extends StatefulWidget { class _ReportScreenState extends State { String? _reason; - static TextStyle _t(double size, Color color, {FontWeight w = FontWeight.w600}) => - TextStyle( - fontFamily: AppFonts.body, - fontSize: size, - fontWeight: w, - height: 1, - letterSpacing: size * -0.025, - color: color, - ); + static TextStyle _t( + double size, + Color color, { + FontWeight w = FontWeight.w600, + }) => TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: w, + height: 1, + letterSpacing: size * -0.025, + color: color, + ); // 시안 `373:442007` — 닫기 X · 「신고하기」 · 오른쪽 「신고」(미선택 #474747), // 작성자(#ee0c2c)/제목 줄, 「신고 사유」 띠 #232325, 사유 줄 48(라디오 20 · 16/600 · 밑줄 #313131). @@ -105,18 +104,29 @@ class _ReportScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Column( children: [ - _info(context.l10n.reportAuthor, widget.target.authorName, const Color(0xFFEE0C2C)), + _info( + context.l10n.reportAuthor, + widget.target.authorName, + const Color(0xFFEE0C2C), + ), const SizedBox(height: 16), const Divider(height: 1, thickness: 1, color: Color(0xFF313131)), const SizedBox(height: 16), - _info(context.l10n.commonTitle, widget.target.title, const Color(0xFFD1D1D2)), + _info( + context.l10n.commonTitle, + widget.target.title, + const Color(0xFFD1D1D2), + ), ], ), ), Container( color: const Color(0xFF232325), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Text(context.l10n.reportReasonLabel, style: _t(12, const Color(0xFF909091))), + child: Text( + context.l10n.reportReasonLabel, + style: _t(12, const Color(0xFF909091)), + ), ), // 디폴트 미선택 · 단수 선택. for (final r in widget.reasons ?? defaultReportReasons) @@ -145,20 +155,26 @@ class _ReportScreenState extends State { children: [ SizedBox( width: 36, - child: Text(label, textAlign: TextAlign.center, style: _t(14, const Color(0xFF59595B))), + child: Text( + label, + textAlign: TextAlign.center, + style: _t(14, const Color(0xFF59595B)), + ), ), const SizedBox(width: 20), Expanded( - child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: _t(14, color)), + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _t(14, color), + ), ), ], ); Future _submit() async { - final ok = await confirmOverlay( - context, - context.l10n.reportConfirm, - ); + final ok = await confirmOverlay(context, context.l10n.reportConfirm); if (!ok || !mounted) return; Navigator.pop(context, _reason); } @@ -226,7 +242,11 @@ Future confirmOverlay(BuildContext context, String message) async { child: Row( children: [ button(c, c.l10n.commonCancel, false), - Container(width: 0.5, height: 42, color: const Color(0xFF636463)), + Container( + width: 0.5, + height: 42, + color: const Color(0xFF636463), + ), button(c, c.l10n.commonConfirm, true), ], ), diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index b1bcf78..30158aa 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -6,6 +6,7 @@ /// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출* library; +import '../campaign/campaign_data.dart' show ContributorRank; import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto; import '../../core/i18n/app_locale.dart'; @@ -69,10 +70,11 @@ List<(T, int)> rankWithTies( num Function(T) value, String Function(T) id, ) { - final sorted = [...items]..sort((a, b) { - final byValue = value(b).compareTo(value(a)); - return byValue != 0 ? byValue : id(a).compareTo(id(b)); - }); + final sorted = [...items] + ..sort((a, b) { + final byValue = value(b).compareTo(value(a)); + return byValue != 0 ? byValue : id(a).compareTo(id(b)); + }); final out = <(T, int)>[]; for (var i = 0; i < sorted.length; i++) { final tied = i > 0 && value(sorted[i]) == value(sorted[i - 1]); @@ -304,12 +306,26 @@ class HeroDropReceipt { final int amount; } -enum PlanetHistoryKind { evolution, hibernation, wakeUp, zap, questReward, companyAdBonus, funding } +enum PlanetHistoryKind { + evolution, + hibernation, + wakeUp, + zap, + questReward, + companyAdBonus, + funding, +} class PlanetHistoryEntry { - const PlanetHistoryEntry({required this.id, required this.kind, - required this.occurredAt, this.fromStage, this.toStage, - this.participationConfirmed = false, this.amount}); + const PlanetHistoryEntry({ + required this.id, + required this.kind, + required this.occurredAt, + this.fromStage, + this.toStage, + this.participationConfirmed = false, + this.amount, + }); final String id; final PlanetHistoryKind kind; final DateTime occurredAt; @@ -340,7 +356,10 @@ abstract interface class PlanetRepository { Future> morePosts({required int offset, int limit = 5}); /// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44). - Future createPost(String body, {List photos = const []}); + Future createPost( + String body, { + List photos = const [], + }); /// 내 글 본문 수정. 사진은 바꾸지 않는다. Future updatePost(LoungePost post, String body); @@ -377,7 +396,13 @@ abstract interface class PlanetRepository { }); /// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」). - Future countryRanking({required FpiMetric metric, int limit = 30}); + Future countryRanking({ + required FpiMetric metric, + int limit = 30, + }); + + /// 성운 「실시간 기여 랭킹」 — 멤버 누적 기여 POP(`members.contributedPop`) 상위. + Future> contributors({int limit = 3}); } /// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든 @@ -398,14 +423,21 @@ class SamplePlanetRepository implements PlanetRepository { @override Future join() async {} + @override + Future> contributors({int limit = 3}) async => const []; + @override Future leave() async {} @override - Future history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []); + Future history({ + String? cursor, + bool personal = false, + }) async => const PlanetHistoryPage(items: []); @override Future claimHeroDrop(String dropId) => Future.error(StateError('실제 지급 서버가 연결되지 않았습니다.')); + /// [fandomId] 는 갤럭시에서 누른 행성이다. 주지 않으면 내 행성으로 본다. const SamplePlanetRepository({String? fandomId}) : fandomId = fandomId ?? _myFandomId; @@ -428,8 +460,10 @@ class SamplePlanetRepository implements PlanetRepository { /// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서 /// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고, /// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다. - (String, String, List) get _row => - _planets.firstWhere((p) => p.$1 == fandomId, orElse: () => _planets.first); + (String, String, List) get _row => _planets.firstWhere( + (p) => p.$1 == fandomId, + orElse: () => _planets.first, + ); /// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인 /// `bpm · health · referral · contribution · community`. @@ -532,9 +566,12 @@ class SamplePlanetRepository implements PlanetRepository { // 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다. rank: nebula ? 0 - : _rank(_planets, metric, myId: row.$1, limit: _planets.length) - .mine! - .rank, + : _rank( + _planets, + metric, + myId: row.$1, + limit: _planets.length, + ).mine!.rank, trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40, // 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면 // 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null). @@ -546,8 +583,10 @@ class SamplePlanetRepository implements PlanetRepository { } @override - Future> morePosts({required int offset, int limit = 5}) async => - _posts(offset, limit); + Future> morePosts({ + required int offset, + int limit = 5, + }) async => _posts(offset, limit); /// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다. static final _comments = >{}; @@ -613,8 +652,10 @@ class SamplePlanetRepository implements PlanetRepository { Future blockAuthor(LoungePost post) async {} @override - Future planetRanking({required FpiMetric metric, int limit = 30}) async => - _rank(_planets, metric, myId: _myFandomId, limit: limit); + Future planetRanking({ + required FpiMetric metric, + int limit = 30, + }) async => _rank(_planets, metric, myId: _myFandomId, limit: limit); /// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면 /// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가 @@ -636,7 +677,10 @@ class SamplePlanetRepository implements PlanetRepository { ); @override - Future countryRanking({required FpiMetric metric, int limit = 30}) async => + Future countryRanking({ + required FpiMetric metric, + int limit = 30, + }) async => // 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713). _rank(_countries, metric, myId: 'KR', limit: limit); diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index 6d3f4d9..1083de5 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -2,6 +2,9 @@ import 'planet_history_screen.dart'; import 'hero_participation_sheet.dart'; import 'package:flutter/material.dart'; +import '../campaign/campaign_data.dart' show ContributorRank; +import '../campaign/widgets/campaign_rank.dart' show RankPodium; + import '../../core/data/backend.dart' show BackendException; import '../../core/data/planet_visual_state.dart'; @@ -66,11 +69,15 @@ class _PlanetDetailScreenState extends State { try { await widget.repository.join(); if (mounted) { - setState(() { _detail = widget.repository.load(nebula: widget.nebula); }); + setState(() { + _detail = widget.repository.load(nebula: widget.nebula); + }); } } on BackendException catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); } } } @@ -89,11 +96,15 @@ class _PlanetDetailScreenState extends State { try { await widget.repository.leave(); if (mounted) { - setState(() { _detail = widget.repository.load(nebula: widget.nebula); }); + setState(() { + _detail = widget.repository.load(nebula: widget.nebula); + }); } } on BackendException catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); } } }), @@ -108,7 +119,9 @@ class _PlanetDetailScreenState extends State { } final d = snap.data; if (d == null) { - return Scaffold(body: Center(child: Text(context.l10n.commonLoadFailed))); + return Scaffold( + body: Center(child: Text(context.l10n.commonLoadFailed)), + ); } // 시안(373:441117)은 히어로 543 아래 「FPI | 팬덤 라운지」 탭이 오고 화면 전체가 // 한 번에 스크롤된다. 스크롤하면 히어로가 접혀 앱 바(행성 축소판 + 이름)와 탭만 @@ -205,8 +218,14 @@ class _PlanetDetailScreenState extends State { ), ); }, destructive: false), - ActionSheetItem(context.l10n.reportTitle, () => _moderate(context, 'report', name)), - ActionSheetItem(context.l10n.commonBlockAction, () => _moderate(context, 'block', name)), + ActionSheetItem( + context.l10n.reportTitle, + () => _moderate(context, 'report', name), + ), + ActionSheetItem( + context.l10n.commonBlockAction, + () => _moderate(context, 'block', name), + ), ]); Future _moderate( @@ -219,11 +238,15 @@ class _PlanetDetailScreenState extends State { .push( MaterialPageRoute( builder: (_) => ReportScreen( - target: ModerationTarget(authorName: name, title: context.l10n.planetNamed(name)), + target: ModerationTarget( + authorName: name, + title: context.l10n.planetNamed(name), + ), ), ), ); - if (reason != null && context.mounted) _toast(context, context.l10n.reportReceived); + if (reason != null && context.mounted) + _toast(context, context.l10n.reportReceived); return; } final ok = await confirmBlock( @@ -408,7 +431,7 @@ class _FpiTab extends StatelessWidget { padding: const EdgeInsets.all(16), sliver: SliverList.list( children: [ - _NebulaFpi(pop: detail.growthPop), + _NebulaFpi(pop: detail.growthPop, repository: repository), ], ), ), @@ -502,9 +525,10 @@ class _FpiTab extends StatelessWidget { /// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`. /// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다. class _NebulaFpi extends StatelessWidget { - const _NebulaFpi({required this.pop}); + const _NebulaFpi({required this.pop, required this.repository}); final int pop; + final PlanetRepository repository; static TextStyle _b(double size, Color color, {double? height}) => TextStyle( fontFamily: AppFonts.body, @@ -531,7 +555,10 @@ class _NebulaFpi extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(context.l10n.planetContribution, style: _b(18, AppColors.onCosmic)), + Text( + context.l10n.planetContribution, + style: _b(18, AppColors.onCosmic), + ), const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -589,57 +616,72 @@ class _NebulaFpi extends StatelessWidget { const SizedBox(height: 16), Text(context.l10n.planetLiveRanking, style: _b(18, AppColors.onCosmic)), const SizedBox(height: 12), - Column( - children: [ - SizedBox.square( - dimension: 24, - child: Stack( - alignment: Alignment.center, - children: [ - Image.asset( - 'assets/figma/icon-crown.png', - width: 24, - height: 24, - color: const Color(0xFFFEA300), - ), - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - '1', - style: _b( - 10, - AppColors.onCosmic, - height: 1, - ).copyWith(fontWeight: FontWeight.w900), - ), - ), - ], - ), - ), - Container( - width: 80, - height: 80, - decoration: const BoxDecoration( - color: Color(0xFF1A1A1C), - shape: BoxShape.circle, - ), - ), - const SizedBox(height: 8), - Text( - context.l10n.planetNoContributors, - style: _b(14, const Color(0xFFCDCDCD), height: 1), - ), - const SizedBox(height: 4), - Text( - context.l10n.planetContributeCta, - style: _b(16, AppColors.onCosmic, height: 22 / 16), - ), - const SizedBox(height: 20), - ], + FutureBuilder>( + future: repository.contributors(), + builder: (context, snap) { + final top = snap.data ?? const []; + if (top.isNotEmpty) { + // 캠페인 상세와 같은 단상(2 · 1 · 3) — 시안 `373:442708` 랭킹 자리. + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: RankPodium(top: top), + ); + } + return _empty(context); + }, ), ], ); } + + Widget _empty(BuildContext context) => Column( + children: [ + SizedBox.square( + dimension: 24, + child: Stack( + alignment: Alignment.center, + children: [ + Image.asset( + 'assets/figma/icon-crown.png', + width: 24, + height: 24, + color: const Color(0xFFFEA300), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '1', + style: _b( + 10, + AppColors.onCosmic, + height: 1, + ).copyWith(fontWeight: FontWeight.w900), + ), + ), + ], + ), + ), + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: Color(0xFF1A1A1C), + shape: BoxShape.circle, + ), + ), + const SizedBox(height: 8), + Text( + context.l10n.planetNoContributors, + style: _b(14, const Color(0xFFCDCDCD), height: 1), + ), + const SizedBox(height: 4), + Text( + context.l10n.planetContributeCta, + style: _b(16, AppColors.onCosmic, height: 22 / 16), + ), + const SizedBox(height: 20), + ], + ); } /// 지표 카드 — 시안 `Frame 1984081152`(#19191c r16 · 안쪽 12 · 높이 117). @@ -723,7 +765,9 @@ class _ScoreCard extends StatelessWidget { // 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」. Text( score.rank > 0 - ? context.l10n.planetOverallRank('${score.rank}') + ? context.l10n.planetOverallRank( + '${score.rank}', + ) : context.l10n.planetRankNone, style: _t(12, color), ), @@ -779,14 +823,19 @@ class _ScoreCard extends StatelessWidget { ), child: Row( children: [ - Text(context.l10n.planetMyContribution, style: _t(10, AppColors.onCosmic, height: 1)), + Text( + context.l10n.planetMyContribution, + style: _t(10, AppColors.onCosmic, height: 1), + ), const Spacer(), Text( // 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는 // 최하위라는 사실 주장이 되어 버린다(대조표 3-5). score.myTopPercent == null ? '—' - : context.l10n.planetTopPercent('${score.myTopPercent}'), + : context.l10n.planetTopPercent( + '${score.myTopPercent}', + ), style: _t(10, AppColors.onCosmic, height: 1), ), ], @@ -914,10 +963,7 @@ class _LoungeTabState extends State<_LoungeTab> { void _replace(LoungePost old, LoungePost? post) => setState(() { _posts = [ for (final p in _posts) - if (p.id != old.id) - p - else if (post != null) - post, + if (p.id != old.id) p else if (post != null) post, ]; }); @@ -933,21 +979,26 @@ class _LoungeTabState extends State<_LoungeTab> { /// 내 글이면 수정 · 삭제를 띄운다. void _postMore(LoungePost post) => showActionSheet(context, [ if (post.isMine) ...[ - ActionSheetItem(context.l10n.commonEditAction, destructive: false, () async { - final updated = await Navigator.of(context, rootNavigator: true) - .push( - MaterialPageRoute( - builder: (_) => LoungeComposeScreen( - initialBody: post.body, - allowPhotos: false, - onSubmit: (d) => widget.repository.updatePost(post, d.body), + ActionSheetItem( + context.l10n.commonEditAction, + destructive: false, + () async { + final updated = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => LoungeComposeScreen( + initialBody: post.body, + allowPhotos: false, + onSubmit: (d) => widget.repository.updatePost(post, d.body), + ), ), - ), - ); - if (updated != null && mounted) _replace(post, updated); - }), + ); + if (updated != null && mounted) _replace(post, updated); + }, + ), ActionSheetItem(context.l10n.commonDeleteAction, () async { - if (!await confirmOverlay(context, context.l10n.postDeleteConfirm)) return; + if (!await confirmOverlay(context, context.l10n.postDeleteConfirm)) + return; await _run(() async { await widget.repository.deletePost(post); _replace(post, null); diff --git a/app/lib/features/planet/planet_history_screen.dart b/app/lib/features/planet/planet_history_screen.dart index 8308b42..8980381 100644 --- a/app/lib/features/planet/planet_history_screen.dart +++ b/app/lib/features/planet/planet_history_screen.dart @@ -35,7 +35,10 @@ class _PlanetHistoryScreenState extends State { _failed = false; }); try { - final page = await widget.repository.history(cursor: _cursor, personal: _personal); + final page = await widget.repository.history( + cursor: _cursor, + personal: _personal, + ); if (!mounted) return; setState(() { final ids = _items.map((item) => item.id).toSet(); @@ -60,18 +63,25 @@ class _PlanetHistoryScreenState extends State { const SizedBox(height: 16), SegmentedButton( segments: [ - ButtonSegment(value: false, label: Text(context.l10n.homeFandomHistory)), + ButtonSegment( + value: false, + label: Text(context.l10n.homeFandomHistory), + ), ButtonSegment(value: true, label: Text(context.l10n.historyMine)), ], selected: {_personal}, - onSelectionChanged: _loading ? null : (selection) { - if (selection.single == _personal) return; - setState(() { - _personal = selection.single; - _items.clear(); _cursor = null; _loaded = false; - }); - _load(); - }, + onSelectionChanged: _loading + ? null + : (selection) { + if (selection.single == _personal) return; + setState(() { + _personal = selection.single; + _items.clear(); + _cursor = null; + _loaded = false; + }); + _load(); + }, ), const SizedBox(height: 16), for (final item in _items) @@ -87,11 +97,10 @@ class _PlanetHistoryScreenState extends State { PlanetHistoryKind.funding => Icons.favorite_border, }), title: Text(switch (item.kind) { - PlanetHistoryKind.evolution => - context.l10n.historyEvolution( - '${item.fromStage}', - '${item.toStage}', - ), + PlanetHistoryKind.evolution => context.l10n.historyEvolution( + '${item.fromStage}', + '${item.toStage}', + ), PlanetHistoryKind.hibernation => context.l10n.historyHibernation, PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp, PlanetHistoryKind.zap => context.l10n.historyZap, @@ -104,7 +113,8 @@ class _PlanetHistoryScreenState extends State { MaterialLocalizations.of( context, ).formatMediumDate(item.occurredAt.toLocal()), - if (item.amount != null) context.l10n.historyGrowthPop('${item.amount}'), + if (item.amount != null) + context.l10n.historyGrowthPop('${item.amount}'), if (item.participationConfirmed) context.l10n.historyJoined, ].join('\n'), ), @@ -114,9 +124,11 @@ class _PlanetHistoryScreenState extends State { Text(context.l10n.historyLoadFailed), TextButton(onPressed: _load, child: Text(context.l10n.commonRetry)), ] else if (_loaded && _items.isEmpty && _cursor == null) - Text(_personal + Text( + _personal ? context.l10n.historyMineEmpty - : context.l10n.historyFandomEmpty), + : context.l10n.historyFandomEmpty, + ), if (!_loading && !_failed && _cursor != null) TextButton(onPressed: _load, child: Text(context.l10n.historyMore)), ], diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index c0ce891..86c30ef 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -2,6 +2,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_storage/firebase_storage.dart'; import '../../core/data/backend.dart'; +import '../campaign/campaign_data.dart' show ContributorRank; import '../my/my_data.dart' show PickedPhoto, PhotoRejected; import '../my/my_repository_firestore.dart'; import 'moderation.dart' show reportReasonCodes; @@ -32,10 +33,14 @@ class FirestorePlanetRepository implements PlanetRepository { final String fandomId; @override - Future history({String? cursor, bool personal = false}) async { + Future history({ + String? cursor, + bool personal = false, + }) async { _backend.requireUid; final result = await _backend.call('fnPlanetHistory', { - 'fandomId': fandomId, if (cursor != null) 'cursor': cursor, + 'fandomId': fandomId, + if (cursor != null) 'cursor': cursor, if (personal) 'scope': 'personal', }); final rows = result['items']; @@ -56,23 +61,46 @@ class FirestorePlanetRepository implements PlanetRepository { 'funding' => PlanetHistoryKind.funding, _ => null, }; - final date = row['occurredAt'] is String ? DateTime.tryParse(row['occurredAt']) : null; + final date = row['occurredAt'] is String + ? DateTime.tryParse(row['occurredAt']) + : null; final id = row['id']; final from = row['fromStage']; final to = row['toStage']; final amount = row['amount']; - final activity = [PlanetHistoryKind.zap, PlanetHistoryKind.questReward, PlanetHistoryKind.companyAdBonus, PlanetHistoryKind.funding].contains(kind); - if (activity != personal || (activity && (amount is! int || amount <= 0)) || kind == null || date == null || id is! String || id.isEmpty || + final activity = [ + PlanetHistoryKind.zap, + PlanetHistoryKind.questReward, + PlanetHistoryKind.companyAdBonus, + PlanetHistoryKind.funding, + ].contains(kind); + if (activity != personal || + (activity && (amount is! int || amount <= 0)) || + kind == null || + date == null || + id is! String || + id.isEmpty || (kind == PlanetHistoryKind.evolution && - (from is! int || to is! int || from < 0 || to > 6 || to <= from))) { + (from is! int || + to is! int || + from < 0 || + to > 6 || + to <= from))) { throw StateError('역사 기록을 확인할 수 없습니다.'); } - items.add(PlanetHistoryEntry(id: id, kind: kind, occurredAt: date, - amount: activity ? amount as int : null, - fromStage: kind == PlanetHistoryKind.evolution ? from as int : null, - toStage: kind == PlanetHistoryKind.evolution ? to as int : null, - participationConfirmed: kind == PlanetHistoryKind.evolution && row['participationConfirmed'] == true, - )); + items.add( + PlanetHistoryEntry( + id: id, + kind: kind, + occurredAt: date, + amount: activity ? amount as int : null, + fromStage: kind == PlanetHistoryKind.evolution ? from as int : null, + toStage: kind == PlanetHistoryKind.evolution ? to as int : null, + participationConfirmed: + kind == PlanetHistoryKind.evolution && + row['participationConfirmed'] == true, + ), + ); } return PlanetHistoryPage(items: items, nextCursor: next as String?); } @@ -81,7 +109,8 @@ class FirestorePlanetRepository implements PlanetRepository { Future claimHeroDrop(String dropId) async { _backend.requireUid; final result = await _backend.call('fnHeroDropClaim', { - 'fandomId': fandomId, 'dropId': dropId, + 'fandomId': fandomId, + 'dropId': dropId, }); final amount = result['claimedPop']; final txId = result['txId']; @@ -104,7 +133,8 @@ class FirestorePlanetRepository implements PlanetRepository { Future join() => _backend.call('fnFandomJoin', {'fandomId': fandomId}); @override - Future leave() => _backend.call('fnFandomLeave', {'fandomId': fandomId}); + Future leave() => + _backend.call('fnFandomLeave', {'fandomId': fandomId}); @override Future load({required bool nebula}) async { @@ -137,9 +167,8 @@ class FirestorePlanetRepository implements PlanetRepository { // `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다. // **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로 // 채우면 없는 값이 사실처럼 보인다. - int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num - ? myTop.intOr(_axisKey(metric)) - : null; + int? myTopOf(FpiMetric metric) => + myTop[_axisKey(metric)] is num ? myTop.intOr(_axisKey(metric)) : null; // 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어 // 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다. final snapshot = snapshots.isEmpty @@ -182,8 +211,9 @@ class FirestorePlanetRepository implements PlanetRepository { : axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)), // 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면 // 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다. - myTopPercent: - axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null, + myTopPercent: axes.intOr(_axisKey(metric)) > 0 + ? myTopOf(metric) + : null, ), ], posts: posts, @@ -276,8 +306,10 @@ class FirestorePlanetRepository implements PlanetRepository { final axis = _axisKey(metric); final uid = _backend.requireUid; final members = _backend.db.collection('fandoms/$fandomId/members'); - final snap = - await members.orderBy('fpiAxes.$axis', descending: true).limit(limit).get(); + final snap = await members + .orderBy('fpiAxes.$axis', descending: true) + .limit(limit) + .get(); // 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순. final rows = [ @@ -286,12 +318,19 @@ class FirestorePlanetRepository implements PlanetRepository { (d) => _raw(d.data(), axis), (d) => d.id, )) - _userRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank, uid: uid), + _userRow( + doc.data(), + id: doc.id, + axis: axis, + fallbackRank: rank, + uid: uid, + ), ]; // 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로 // 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다. - final note = snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num) + final note = + snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num) ? null : '사용자 순위 동향은 아직 집계 전입니다'; @@ -309,17 +348,23 @@ class FirestorePlanetRepository implements PlanetRepository { final fallback = serverRank is num ? 0 : ((await members - .where('fpiAxes.$axis', isGreaterThan: _raw(me, axis)) - .count() - .get()) - .count ?? - rows.length) + - 1; + .where('fpiAxes.$axis', isGreaterThan: _raw(me, axis)) + .count() + .get()) + .count ?? + rows.length) + + 1; return FpiRanking( rows: rows, mockNote: note, - mine: _userRow(me, id: uid, axis: axis, fallbackRank: fallback, uid: uid, - name: appL10n.commonMe), + mine: _userRow( + me, + id: uid, + axis: axis, + fallbackRank: fallback, + uid: uid, + name: appL10n.commonMe, + ), ); } @@ -346,6 +391,30 @@ class FirestorePlanetRepository implements PlanetRepository { ); } + @override + Future> contributors({int limit = 3}) async { + final uid = _backend.requireUid; + final snap = await _backend.db + .collection('fandoms/$fandomId/members') + .orderBy('contributedPop', descending: true) + .limit(limit) + .get(); + return [ + for (final (doc, rank) in rankWithTies( + snap.docs.where((d) => d.data().intOr('contributedPop') > 0).toList(), + (d) => d.data().intOr('contributedPop'), + (d) => d.id, + )) + ContributorRank( + rank: rank, + // UID·이메일을 이름 자리에 내보내지 않는다(G-07). + name: publicName(doc.data()['nickname'], null), + pop: doc.data().intOr('contributedPop'), + isMe: doc.id == uid, + ), + ]; + } + @override Future countryRanking({ required FpiMetric metric, @@ -353,34 +422,37 @@ class FirestorePlanetRepository implements PlanetRepository { }) async { // 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」. // 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다. - final doc = await _backend.db.doc('rankings/country-${_axisKey(metric)}').get(); + final doc = await _backend.db + .doc('rankings/country-${_axisKey(metric)}') + .get(); final rows = (doc.data() ?? const {}).mapsOr('rows'); if (rows.isEmpty) { - return const FpiRanking( - rows: [], - mockNote: '국가별 집계가 아직 없습니다', - ); + return const FpiRanking(rows: [], mockNote: '국가별 집계가 아직 없습니다'); } // 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다). // 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을 // 찾아 올린다. - final me = (await _backend.db.doc('fandoms/$fandomId/members/${_backend.requireUid}') - .get()) - .data(); + final me = + (await _backend.db + .doc('fandoms/$fandomId/members/${_backend.requireUid}') + .get()) + .data(); final myCode = (me ?? const {}).strOr('countryCode', 'ZZ'); final list = []; for (final (i, row) in rows.take(limit).indexed) { final code = row.strOr('code', 'ZZ'); - list.add(FpiRankRow( - id: code, - rank: row.intOr('rank', i + 1), - // 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]). - name: countryName(code), - value: row.intOr('value'), - change: row.intOr('change'), - isMine: code == myCode, - )); + list.add( + FpiRankRow( + id: code, + rank: row.intOr('rank', i + 1), + // 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]). + name: countryName(code), + value: row.intOr('value'), + change: row.intOr('change'), + isMine: code == myCode, + ), + ); } return FpiRanking( rows: list, @@ -432,16 +504,19 @@ class FirestorePlanetRepository implements PlanetRepository { } static Map _map(Map data, String key) => - Map.from((data[key] as Map?) ?? const {}); + Map.from( + (data[key] as Map?) ?? const {}, + ); /// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다. Set? _blocked; Future> _blockedUids() async => _blocked ??= { - for (final d in (await _backend.db - .collection('users/${_backend.requireUid}/blocks') - .get()) - .docs) + for (final d + in (await _backend.db + .collection('users/${_backend.requireUid}/blocks') + .get()) + .docs) d.id, }; diff --git a/app/lib/features/planet/widgets/planet_detail_hero.dart b/app/lib/features/planet/widgets/planet_detail_hero.dart index eac4eb3..1aa1891 100644 --- a/app/lib/features/planet/widgets/planet_detail_hero.dart +++ b/app/lib/features/planet/widgets/planet_detail_hero.dart @@ -217,7 +217,9 @@ class PlanetDetailHero extends StatelessWidget { Text('|', style: _body(14, info)), const SizedBox(width: 4), Text( - context.l10n.planetPerHead(perHead.toStringAsFixed(2)), + context.l10n.planetPerHead( + perHead.toStringAsFixed(2), + ), style: _body(14, info), ), ], @@ -239,7 +241,9 @@ class PlanetDetailHero extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.full), ), child: Text( - joined ? context.l10n.planetJoined : context.l10n.planetJoin, + joined + ? context.l10n.planetJoined + : context.l10n.planetJoin, style: _body( 14, const Color(0xFFD5D7D9), diff --git a/app/test/f3_screens_test.dart b/app/test/f3_screens_test.dart index fe8a4bb..04417db 100644 --- a/app/test/f3_screens_test.dart +++ b/app/test/f3_screens_test.dart @@ -5,6 +5,7 @@ import 'package:fancake/features/planet/fpi_ranking_screen.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fancake/features/campaign/campaign_data.dart' show ContributorRank; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/planet/lounge_post_screens.dart'; @@ -92,6 +93,15 @@ class _NoTopPercentRepository extends SamplePlanetRepository { } /// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본. +class _Contributed extends SamplePlanetRepository { + const _Contributed(); + + @override + Future> contributors({int limit = 3}) async => const [ + ContributorRank(rank: 1, name: '별빛수집가', pop: 990), + ]; +} + class _Joinable extends SamplePlanetRepository { _Joinable(); int joins = 0; @@ -381,6 +391,17 @@ void main() { )); expect(find.text('New_BTS'), findsOneWidget); }); + + testWidgets('기여자가 있으면 실시간 기여 랭킹에 단상이 그려진다(10/5 실기기)', (tester) async { + await _pump(tester, const PlanetDetailScreen( + nebula: true, + repository: _Contributed(), + eclipse: NoEclipseRepository(), + )); + await tester.pumpAndSettle(); + expect(find.text('별빛수집가', skipOffstage: false), findsOneWidget); + expect(find.text('아직 기여자가 없어요', skipOffstage: false), findsNothing); + }); }); testWidgets('상대 시각 표기', (tester) async {