From 5de04ac281fb8ce2c75dd38a2a3ec68a0c80217e Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 29 Sep 2026 17:06:48 +0900 Subject: [PATCH] =?UTF-8?q?HOME=20=C2=B7=20GALAXY=20=C2=B7=20=ED=96=89?= =?UTF-8?q?=EC=84=B1=C2=B7=EC=84=B1=EC=9A=B4=20=EC=83=81=EC=84=B8=20=C2=B7?= =?UTF-8?q?=20=ED=8C=AC=EB=8D=A4=20=EB=9D=BC=EC=9A=B4=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=ED=81=B4=EB=A6=BD=EC=8A=A4=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/eclipse/eclipse_data.dart | 249 ++++ .../eclipse/eclipse_defend_screen.dart | 697 +++++++++++ .../eclipse/eclipse_repository_firestore.dart | 205 +++ .../eclipse/widgets/eclipse_banner.dart | 154 +++ .../features/eclipse/widgets/live_proof.dart | 27 + app/lib/features/galaxy/galaxy_data.dart | 218 ++++ .../features/galaxy/galaxy_home_screen.dart | 447 +++++++ app/lib/features/galaxy/galaxy_motion.dart | 148 +++ .../galaxy/galaxy_repository_firestore.dart | 154 +++ app/lib/features/galaxy/galaxy_screen.dart | 1092 +++++++++++++++- .../features/galaxy/galaxy_search_screen.dart | 297 +++++ .../features/galaxy/widgets/galaxy_field.dart | 398 ++++++ .../galaxy/widgets/galaxy_mini_map.dart | 802 ++++++++++++ .../features/galaxy/widgets/zap_button.dart | 309 +++++ .../features/galaxy/widgets/zap_feedback.dart | 209 ++++ app/lib/features/galaxy/zap_controller.dart | 85 ++ .../features/home/growth_complete_screen.dart | 84 ++ app/lib/features/home/home_data.dart | 529 ++++++++ .../features/home/home_onboarding_screen.dart | 57 + .../home/home_repository_firestore.dart | 365 ++++++ app/lib/features/home/home_screen.dart | 695 ++++++++++- .../features/home/nebula_create_screen.dart | 443 +++++++ .../features/home/planet_create_screen.dart | 422 +++++++ .../features/home/widgets/briefing_list.dart | 132 ++ .../features/home/widgets/briefing_share.dart | 285 +++++ .../features/home/widgets/campaign_card.dart | 129 ++ .../home/widgets/growth_conditions.dart | 55 + app/lib/features/home/widgets/home_hero.dart | 813 ++++++++++++ .../features/home/widgets/inside_card.dart | 86 ++ app/lib/features/home/widgets/item_sheet.dart | 271 ++++ .../features/home/widgets/live_ticker.dart | 34 + .../home/widgets/planet_growth_card.dart | 89 ++ app/lib/features/home/widgets/quest_row.dart | 239 ++++ .../features/planet/fpi_ranking_screen.dart | 489 ++++++++ .../planet/hero_participation_sheet.dart | 136 ++ .../features/planet/lounge_post_screens.dart | 791 ++++++++++++ app/lib/features/planet/moderation.dart | 239 ++++ app/lib/features/planet/planet_data.dart | 651 ++++++++++ .../features/planet/planet_detail_screen.dart | 1104 +++++++++++++++++ .../planet/planet_history_screen.dart | 125 ++ .../planet/planet_repository_firestore.dart | 665 ++++++++++ .../features/planet/widgets/action_sheet.dart | 94 ++ .../features/planet/widgets/fpi_hexagon.dart | 143 +++ .../planet/widgets/planet_detail_hero.dart | 258 ++++ 44 files changed, 14898 insertions(+), 16 deletions(-) create mode 100644 app/lib/features/eclipse/eclipse_data.dart create mode 100644 app/lib/features/eclipse/eclipse_defend_screen.dart create mode 100644 app/lib/features/eclipse/eclipse_repository_firestore.dart create mode 100644 app/lib/features/eclipse/widgets/eclipse_banner.dart create mode 100644 app/lib/features/eclipse/widgets/live_proof.dart create mode 100644 app/lib/features/galaxy/galaxy_data.dart create mode 100644 app/lib/features/galaxy/galaxy_home_screen.dart create mode 100644 app/lib/features/galaxy/galaxy_motion.dart create mode 100644 app/lib/features/galaxy/galaxy_repository_firestore.dart create mode 100644 app/lib/features/galaxy/galaxy_search_screen.dart create mode 100644 app/lib/features/galaxy/widgets/galaxy_field.dart create mode 100644 app/lib/features/galaxy/widgets/galaxy_mini_map.dart create mode 100644 app/lib/features/galaxy/widgets/zap_button.dart create mode 100644 app/lib/features/galaxy/widgets/zap_feedback.dart create mode 100644 app/lib/features/galaxy/zap_controller.dart create mode 100644 app/lib/features/home/growth_complete_screen.dart create mode 100644 app/lib/features/home/home_data.dart create mode 100644 app/lib/features/home/home_onboarding_screen.dart create mode 100644 app/lib/features/home/home_repository_firestore.dart create mode 100644 app/lib/features/home/nebula_create_screen.dart create mode 100644 app/lib/features/home/planet_create_screen.dart create mode 100644 app/lib/features/home/widgets/briefing_list.dart create mode 100644 app/lib/features/home/widgets/briefing_share.dart create mode 100644 app/lib/features/home/widgets/campaign_card.dart create mode 100644 app/lib/features/home/widgets/growth_conditions.dart create mode 100644 app/lib/features/home/widgets/home_hero.dart create mode 100644 app/lib/features/home/widgets/inside_card.dart create mode 100644 app/lib/features/home/widgets/item_sheet.dart create mode 100644 app/lib/features/home/widgets/live_ticker.dart create mode 100644 app/lib/features/home/widgets/planet_growth_card.dart create mode 100644 app/lib/features/home/widgets/quest_row.dart create mode 100644 app/lib/features/planet/fpi_ranking_screen.dart create mode 100644 app/lib/features/planet/hero_participation_sheet.dart create mode 100644 app/lib/features/planet/lounge_post_screens.dart create mode 100644 app/lib/features/planet/moderation.dart create mode 100644 app/lib/features/planet/planet_data.dart create mode 100644 app/lib/features/planet/planet_detail_screen.dart create mode 100644 app/lib/features/planet/planet_history_screen.dart create mode 100644 app/lib/features/planet/planet_repository_firestore.dart create mode 100644 app/lib/features/planet/widgets/action_sheet.dart create mode 100644 app/lib/features/planet/widgets/fpi_hexagon.dart create mode 100644 app/lib/features/planet/widgets/planet_detail_hero.dart diff --git a/app/lib/features/eclipse/eclipse_data.dart b/app/lib/features/eclipse/eclipse_data.dart new file mode 100644 index 0000000..91a1f04 --- /dev/null +++ b/app/lib/features/eclipse/eclipse_data.dart @@ -0,0 +1,249 @@ +/// 이클립스 방어·침식·라이브 소셜 프루프가 화면에 닿는 형태. +/// +/// **방어는 팬덤 공동 Shake 참여이며 POP 을 소모하지 않는다.** +/// 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · +/// Firestore 설계서 v1.1 §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를 +/// 두지 않습니다. 하루 2 회, 세부 수치는 `config` 운영값*. +/// +/// 별첨 1 ① 시나리오 4 의 「번개 버튼 연타·오토 도네이션」은 별첨 B 가 상위 기준이므로 +/// 따르지 않는다(공통 기준 2항). GALAXY 의 ZAP 버튼은 이클립스 방어가 아니라 +/// **행성 성장 기여**다(B-23) — [GalaxyRepository] 가 맡는다. +/// +/// v1.5 §5 확정: 수동 방어는 Shake only · POP 무소모, HOME ZAP은 성장 기여다. +/// +/// 수치는 **전부 서버 산출값**이다. 화면은 침식률을 계산하지 않는다 — 단말마다 +/// 다른 값을 그리면 「방어했는데 침식률이 안 준다」가 된다. +library; + +/// 활성 이클립스 한 건의 현재 상태. +enum EclipseResult { success, failure, cancelled } + +class EclipseState { + const EclipseState({ + required this.fandomId, + required this.eclipseId, + required this.fandomName, + required this.erosionRate, + required this.defense, + required this.targetDefense, + required this.defenseCount, + required this.defended, + this.qualifyShakes = 60, + this.endsAt, + this.result, + this.mockNote, + }); + + final String fandomId; + final String eclipseId; + + /// 배너 문구의 「{행성}이 침식되고 있습니다」 자리. + final String fandomName; + + /// 서버가 산출한 침식률 0~100. `fn-eclipse-aggregate` 의 `erosionRate`. + final int erosionRate; + + /// 서버가 60회 이상으로 인정한 고유 참여자 수. 샤드 합으로 즉시 반영한다. + final int defense; + + /// DAU 평균과 목표율로 서버가 확정한 목표 인원. `eclipseRounds.target`(C-26). + final int targetDefense; + + /// 고유 참여자 1명으로 인정하는 개인 유효 Shake 수. 회차 문서의 운영값(C-25 기본 60). + final int qualifyShakes; + + /// 방어에 참여한 횟수 합. + final int defenseCount; + + /// 목표를 채워 방어에 성공했는가. + final bool defended; + + /// 서버 확정 판정. 단말 타이머 만료는 failure가 아니다. + final EclipseResult? result; + bool get canDefend => result == null && !defended && endsAt != null && !isExpired(DateTime.now()); + + /// 방어 마감. 없으면 남은 시간을 띄우지 않는다. + final DateTime? endsAt; + + /// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5). + final String? mockNote; + + bool get isMock => mockNote != null; + + /// 방어 진행률 0~1. 목표가 0 이면 진행률을 말할 수 없다. + double get progress => + targetDefense <= 0 ? 0 : (defense / targetDefense).clamp(0.0, 1.0); + + /// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 D3) — + /// 서버가 `ended` 로 닫기 전이라도 화면이 먼저 접는다. + bool isExpired(DateTime now) { + final at = endsAt; + return at != null && !at.isAfter(now); + } + + /// 남은 시간. 마감이 없거나 지났으면 null. + Duration? remaining(DateTime now) { + final at = endsAt; + if (at == null) return null; + final left = at.difference(now); + return left.isNegative ? null : left; + } +} + +/// 방어 한 번의 결과. **서버가 확정한 값만 담는다.** +class DefendResult { + const DefendResult({ + required this.accepted, + required this.clamped, + required this.myTotal, + required this.duplicate, + }); + + /// 이번 참여로 집계에 더해진 방어량. 상한에 걸리면 보낸 값보다 작다. + final int accepted; + + /// 상한 때문에 깎인 양. 화면이 「참여 상한에 닿았습니다」를 띄우는 근거다. + final int clamped; + + /// 이 이벤트에서 내가 지금까지 쌓은 방어량. + final int myTotal; + + /// 재시도로 판정되어 아무것도 더하지 않았는가. + final bool duplicate; +} + +/// 라이브 소셜 프루프 한 줄 — `fandoms/{id}/liveEvents` 한 문서. +/// +/// 별첨 B **「Global Ticker」** 확정에 따라 이 컬렉션은 이클립스 전용이 아니다 — +/// *Eclipse·Artist Hot Time·Hero Gift·Campaign·공지를 하나의 Global Ticker 로 통합하고 +/// 우선순위·클릭이동처를 운영설정으로 관리*. 그래서 [kind] 로 갈라 두었다. +class LiveEvent { + const LiveEvent({ + required this.id, + required this.nickname, + required this.at, + this.kind = LiveEventKind.eclipseDefense, + this.defense = 0, + this.priority = 50, + this.linkTo = '', + }); + + /// 문서 ID. 같은 줄을 두 번 흘리지 않도록 위젯이 키로 쓴다. + final String id; + + final String nickname; + + /// 이클립스 방어 참여량. 다른 종류에서는 0 이다. + final int defense; + + final DateTime at; + final LiveEventKind kind; + + /// 낮을수록 먼저 뜬다. 종류별 값은 운영설정에서 온다. + final int priority; + + /// 눌렀을 때 갈 곳. 운영설정 항목이라 화면이 만들지 않는다. + final String linkTo; +} + +/// 티커 이벤트 종류. 서버의 `kind` 문자열과 1:1 이다. +enum LiveEventKind { + eclipseDefense('eclipse_defense'), + artistHotTime('artist_hot_time'), + heroGift('hero_gift'), + campaign('campaign'), + notice('notice'); + + const LiveEventKind(this.wire); + + /// Firestore 에 적힌 값. 이 문자열이 서버와의 계약면이다. + final String wire; + + /// 모르는 값은 공지로 읽는다 — 새 종류가 늘어도 티커가 멈추지 않는다. + static LiveEventKind of(String value) => + values.where((k) => k.wire == value).firstOrNull ?? LiveEventKind.notice; +} + +/// 이클립스가 화면에 닿는 유일한 경로. +/// +/// 읽기는 Firestore 구독, **쓰기(방어)는 Cloud Functions** 다 — 별첨 1 ② +/// 「클라이언트 단독 지급 0건」. 방어는 POP 을 쓰지 않지만 집계를 움직이므로 +/// 클라이언트가 직접 쓰지 못하게 같은 경계를 지킨다. +abstract interface class EclipseRepository { + /// 활성 이클립스. [fandomId] 를 주지 않으면 내 소속 팬덤을 본다. + /// 기본은 활성 회차만, includeResults는 방어 화면에서 마감 후 판정까지 받는다. + Stream watch({String? fandomId, bool includeResults = false}); + + /// 최근 티커 이벤트. 최신순. + Stream> liveEvents({String? fandomId, int limit = 20}); + + /// 공동 Shake 참여. [shakes] 는 단말이 감지한 흔들기 수이며 + /// **서버가 상한과 속도로 다시 판정한다**(별첨 1 ② 서버검증 원칙). + Future defend({ + required String fandomId, + required String eclipseId, + required String clientSeq, + required int shakes, + }); +} + +/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** +/// +/// 활성 이클립스를 하나 띄워 두는 이유는 별첨 2 의 확인 항목 때문이다. 목 모드에서 +/// 배너가 아예 뜨지 않으면 「이클립스 화면이 없다」로 읽힌다. 대신 `mockNote` 를 +/// 실어 화면이 표본임을 배지로 밝힌다. +class SampleEclipseRepository implements EclipseRepository { + SampleEclipseRepository(); + + static const _note = '표본 이클립스 — 서버 집계 미연동'; + + /// 표본에서도 참여가 쌓이는 것이 보여야 한다. 방어 호출이 이 값을 올린다. + int _defense = 5_800; + + EclipseState _snapshot({String? fandomId}) => EclipseState( + fandomId: fandomId ?? 'bts', + eclipseId: 'sample-eclipse', + fandomName: 'BTS', + erosionRate: 42, + defense: _defense, + targetDefense: 10_000, + defenseCount: 1_340, + defended: false, + endsAt: DateTime.now().add(const Duration(minutes: 18)), + mockNote: _note, + ); + + @override + Stream watch({String? fandomId, bool includeResults = false}) => + Stream.value(_snapshot(fandomId: fandomId)); + + @override + Stream> liveEvents({String? fandomId, int limit = 20}) { + final now = DateTime.now(); + return Stream.value([ + LiveEvent(id: 'e1', nickname: '아미보이', defense: 12, at: now), + LiveEvent( + id: 'e2', + nickname: '버니즈', + defense: 8, + at: now.subtract(const Duration(seconds: 12)), + ), + ].take(limit).toList(growable: false)); + } + + @override + Future defend({ + required String fandomId, + required String eclipseId, + required String clientSeq, + required int shakes, + }) async { + _defense += shakes; + return DefendResult( + accepted: shakes, + clamped: 0, + myTotal: shakes, + duplicate: false, + ); + } +} diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart new file mode 100644 index 0000000..af3edfb --- /dev/null +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -0,0 +1,697 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +import '../../core/data/backend.dart'; +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; +import '../../core/ui/live_stream.dart'; +import '../home/home_screen.dart' show formatPop; +import 'eclipse_data.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면. +/// +/// 근거 — 정책 원장 **B-55** *하루 2 회 **공동 Shake 방어 이벤트*** · +/// Firestore 설계서 v1.1 §9-#5 *방어는 Shake 참여로 이루어지며 **1 회당 POP 소모를 +/// 두지 않는다***. 그래서 이 화면에는 잔액도 차감 표기도 없다. +/// +/// **흔들기 판정을 화면이 최종적으로 하지 않는다.** 단말이 센 횟수는 서버가 상한과 +/// 속도로 다시 판정하며(별첨 1 ② 서버검증 원칙), 화면은 서버가 받아들인 값 +/// ([DefendResult.accepted])만 자기 기여로 표시한다. 단말 값을 그대로 믿고 그리면 +/// 서버가 깎은 뒤에 숫자가 줄어드는 것으로 보인다. +/// +/// `quest/verify.ts` 의 `gradeShake` 는 재사용하지 않았다. 그쪽은 **구간별 박자 +/// 정확도**(perfect·good·miss)를 매기는 Music Shake 판정이고, 이클립스 방어는 +/// 박자가 없는 자유 흔들기 누적이라 판정 모델이 다르다. +class EclipseDefendScreen extends StatefulWidget { + const EclipseDefendScreen({ + super.key, + required this.repository, + required this.fandomId, + }); + + final EclipseRepository repository; + + /// 어느 팬덤의 이클립스인가. 화면은 구독으로 현재 상태를 따라간다. + final String fandomId; + + @override + State createState() => _EclipseDefendScreenState(); +} + +class _EclipseDefendScreenState extends State + with WidgetsBindingObserver { + bool _foreground = true; + String? _roundId; + StreamSubscription? _accel; + + /// 아직 서버에 보내지 않은 흔들기 수. + int _pending = 0; + + /// 서버가 받아들인 내 누적 참여량. + int _mine = 0; + + /// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다. + bool _cappedNotified = false; + + Timer? _burst; + bool _sending = false; + ({String fandomId, String eclipseId, String seq, int shakes})? _batch; + + /// 흔들기로 볼 가속도 크기(m/s²). 중력(9.8)을 넘는 순간만 센다. + static const _shakeThreshold = 18.0; + + /// 한 번의 흔들기로 세는 최소 간격. 이보다 촘촘한 값은 같은 동작의 진동이다. + static const _shakeDebounce = Duration(milliseconds: 220); + + /// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다. + static const _burstWindow = Duration(milliseconds: 700); + + DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _accel = accelerometerEventStream().listen( + _onAccel, + onError: (_) { + // 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고 + // 아래의 「직접 누르기」로 참여할 수 있게 한다. + }, + ); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _burst?.cancel(); + _burst = null; + _accel?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _foreground = state == AppLifecycleState.resumed; + if (!_foreground) { + _burst?.cancel(); + _burst = null; + _pending = 0; + } + } + + void _onAccel(AccelerometerEvent event) { + final magnitude = math.sqrt( + event.x * event.x + event.y * event.y + event.z * event.z, + ); + if (!magnitude.isFinite || magnitude < _shakeThreshold) return; + final now = DateTime.now(); + if (now.difference(_lastShake) < _shakeDebounce) return; + _lastShake = now; + _shake(); + } + + void _shake() { + if (!_foreground || + ModalRoute.of(context)?.isCurrent == false || + _current?.canDefend != true || + (_batch != null && !_sending)) + return; + setState(() => _pending += 1); + _scheduleFlush(); + } + + void _scheduleFlush() { + _burst ??= Timer(_burstWindow, () { + _burst = null; + _flush(); + }); + } + + Future _flush({bool retry = false}) async { + if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent == false) + return; + if (_batch != null && !retry) return; + if (_batch == null) { + final state = _current; + if (_pending == 0 || state == null || !state.canDefend) return; + _batch = ( + fandomId: state.fandomId, + eclipseId: state.eclipseId, + seq: 'shake${DateTime.now().microsecondsSinceEpoch}', + shakes: _pending, + ); + _pending = 0; + } + final batch = _batch!; + setState(() => _sending = true); + try { + final result = await widget.repository.defend( + fandomId: batch.fandomId, + eclipseId: batch.eclipseId, + clientSeq: batch.seq, + shakes: batch.shakes, + ); + if (!mounted || _batch != batch) return; + _batch = null; + if (_current?.eclipseId == batch.eclipseId) { + setState(() => _mine = math.max(_mine, result.myTotal)); + if (result.clamped > 0 && !_cappedNotified) { + _cappedNotified = true; + _notify(context.l10n.eclipseCapped); + } + } + } on BackendException catch (error) { + if (!mounted || _batch != batch) return; + if (['E-ECL-01', 'E-AUTH-03', 'E-PERM-01'].contains(error.code)) + _batch = null; + _notify(_failure(error)); + } catch (_) { + if (!mounted || _batch != batch) return; + _notify(context.l10n.eclipseResultUnknown); + } finally { + if (mounted) { + setState(() => _sending = false); + if (_batch == null && + _pending > 0 && + _foreground && + _current?.canDefend == true) + _scheduleFlush(); + } + } + } + + Widget _retryButton() => TextButton( + onPressed: _sending ? null : () => _flush(retry: true), + child: Text( + _sending ? context.l10n.eclipseChecking : context.l10n.eclipseRetrySame, + ), + ); + + /// 서버 오류 코드를 문구로 옮긴다. 코드로 분기하고 메시지로 분기하지 않는다. + String _failure(BackendException error) => switch (error.code) { + 'E-ECL-01' => context.l10n.eclipseEnded, + 'E-QST-02' => context.l10n.eclipseTooFast, + 'E-AUTH-03' => context.l10n.authLoginRequired, + _ => context.l10n.eclipseResultUnknown, + }; + + void _notify(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + /// 마지막으로 받은 이클립스 상태. 방어 호출이 대상 ID 를 여기서 얻는다. + EclipseState? _current; + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: LiveStreamBuilder( + key: ValueKey('defend-${widget.fandomId}'), + create: () => widget.repository.watch( + fandomId: widget.fandomId, + includeResults: true, + ), + builder: (context, state) { + _current = state; + if (state != null && _roundId != state.eclipseId) { + _roundId = state.eclipseId; + _mine = 0; + _batch = null; + _pending = 0; + _cappedNotified = false; + _burst?.cancel(); + _burst = null; + } + if (state?.canDefend != true) { + _pending = 0; + _burst?.cancel(); + _burst = null; + } + if (state == null) { + return Column( + children: [ + const _CloseBar(), + Expanded(child: Center(child: Text(context.l10n.eclipseNone))), + ], + ); + } + final result = state.result; + if (state.defended || result == EclipseResult.success) { + // 사본 `홈/이클립스 성공`(`373:537406`). + return _Outcome( + title: context.l10n.eclipseSuccessTitle, + success: true, + extra: [ + if (_mine >= state.qualifyShakes) + _Note(context.l10n.eclipseQualified), + if (_batch != null) _retryButton(), + ], + ); + } + if (result == EclipseResult.failure || + result == EclipseResult.cancelled || + state.isExpired(DateTime.now())) { + // 실패는 사본 변형 `494:14292`. 취소·판정 대기는 사본에 없어 같은 틀에 정책 문구를 얹는다. + final (title, message) = switch (result) { + EclipseResult.failure => ( + context.l10n.eclipseFailTitle, + context.l10n.eclipseFailBody, + ), + EclipseResult.cancelled => ( + context.l10n.eclipseCancelTitle, + context.l10n.eclipseCancelBody, + ), + _ => ( + context.l10n.eclipsePendingTitle, + context.l10n.eclipsePendingBody, + ), + }; + return _Outcome( + title: title, + success: false, + // 판정 대기 중에는 닫을 결과가 아직 없다. + confirm: result != null, + extra: [ + _Note(message), + if (_mine >= state.qualifyShakes) + _Note(context.l10n.eclipseQualified), + if (_batch != null) _retryButton(), + ], + ); + } + // 사본 `홈/이클립스 방어`(흔들기 전) → `홈/이클립스 상세`(흔드는 중). + final started = _mine > 0 || _pending > 0 || _batch != null; + return _Countdown( + endsAt: state.endsAt, + builder: (context, seconds) => Column( + children: [ + const _CloseBar(), + const SizedBox(height: 24), + SizedBox( + height: 68, + child: Text.rich( + TextSpan( + children: [ + if (!started && seconds != null) + TextSpan( + text: '$seconds', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w900, + ), + ), + TextSpan( + text: !started && seconds != null + ? context.l10n.eclipseShakeTimed + : context.l10n.eclipseShake, + ), + ], + ), + textAlign: TextAlign.center, + style: _titleStyle, + ), + ), + const SizedBox(height: 24), + // 짧은 화면에서는 아래 안내·버튼이 밀려나지 않게 행성 자리가 먼저 줄어든다. + Flexible( + child: SizedBox( + height: 400, + child: _DefendPlanet(shaking: started, pending: _pending), + ), + ), + const Spacer(), + _Note( + // 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를 + // 묻게 두지 않는다(설계서 v1.1 §9-#5). + '${context.l10n.eclipseMyCount(formatPop(_mine))}' + '${_pending > 0 + ? context.l10n.eclipsePendingSuffix('$_pending') + : _sending + ? context.l10n.eclipseSendingSuffix + : ''}' + '${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}\n' + '${context.l10n.eclipseNoPopNote('${state.qualifyShakes}')}', + ), + if (_batch != null) _retryButton(), + // 센서가 없는 단말에서도 참여할 수 있게 둔다. 판정은 서버가 하므로 + // 이 버튼이 흔들기보다 유리하지 않다(상한·속도 제한이 같다). 사본에 없는 + // 보조 입력이라 채움 없는 낮은 강조로 둔다. + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpace.s4, + AppSpace.s2, + AppSpace.s4, + AppSpace.s4, + ), + child: FilledButton( + onPressed: state.canDefend && (_batch == null || _sending) + ? _shake + : null, + style: FilledButton.styleFrom( + backgroundColor: AppColors.cosmicSurfaceContainer, + foregroundColor: AppColors.onCosmicVariant, + minimumSize: const Size.fromHeight(AppSizing.touchMinApp), + ), + child: Text(context.l10n.eclipseTapToJoin), + ), + ), + // 흔들기 전(`373:445267`)은 남은 시간을 제목의 「N초간」으로 보여 준다. + // 흔드는 중(`373:444865`)은 맨 아래 `Frame 1984081868`(72 높이) 가운데. + if (started && seconds != null) + SizedBox( + height: 72, + child: Center( + child: Text.rich( + TextSpan( + text: context.l10n.eclipseTimeLeft, + children: [ + TextSpan( + text: context.l10n.eclipseSeconds('$seconds'), + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.onCosmic, + ), + ), + ], + ), + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + color: AppColors.onCosmicMuted, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); +} + +/// 사본 제목 — ABeeZee 24 · 행간 140% · 흰색 · 가운데. +const _titleStyle = TextStyle( + fontFamily: AppFonts.display, + fontSize: 24, + fontWeight: FontWeight.w400, + height: 1.4, + color: AppColors.onCosmic, +); + +/// 사본 상단 `wrapper`(56) — 왼쪽 16 에 닫기 X(24). +class _CloseBar extends StatelessWidget { + const _CloseBar(); + + @override + Widget build(BuildContext context) => SizedBox( + height: 56, + child: Align( + alignment: Alignment.centerLeft, + child: IconButton( + constraints: const BoxConstraints.tightFor(width: 56, height: 56), + icon: const ImageIcon(AppIcons.x, size: 24, color: AppColors.onCosmic), + tooltip: context.l10n.commonClose, + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ); +} + +/// 사본에 없는 정책 안내 한 단락 — 12 · #767676 · 가운데. +class _Note extends StatelessWidget { + const _Note(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.4, + color: AppColors.onCosmicMuted, + ), + ), + ); +} + +/// 초 단위로 다시 그린다. 마감은 서버 값이라 재진입해도 처음부터 세지 않는다. +class _Countdown extends StatefulWidget { + const _Countdown({required this.endsAt, required this.builder}); + + final DateTime? endsAt; + final Widget Function(BuildContext context, int? seconds) builder; + + @override + State<_Countdown> createState() => _CountdownState(); +} + +class _CountdownState extends State<_Countdown> { + late final Timer _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {})); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final at = widget.endsAt; + final ms = at?.difference(DateTime.now()).inMilliseconds; + return widget.builder( + context, + ms == null || ms <= 0 ? null : (ms / 1000).ceil(), + ); + } +} + +/// 행성 + 운석 — 사본 `Frame 1984081426`(355×400). +/// +/// 흔들기 전은 검정 30% 원(280) 안의 행성(240)과 운석 셋, 흔드는 중은 행성 양옆에 +/// 흐린 사본을 겹친 「가려지는」 모습(`373:444966` 등, 흐림 4)이다. +class _DefendPlanet extends StatelessWidget { + const _DefendPlanet({required this.shaking, required this.pending}); + + final bool shaking; + final int pending; + + static const _planet = AssetImage('assets/figma/planet-home.png'); + + // 사본 좌표(프레임 원점 x10 · y220 기준). + static Widget _at(double x, double y, double w, double h, Widget child) => + Positioned(left: x - 10, top: y - 220, width: w, height: h, child: child); + + static Widget _shadow(double x, double y, double size, {double blur = 0}) => + _at( + x, + y, + size, + size, + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: const ColorFiltered( + colorFilter: ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop), + child: Image(image: _planet), + ), + ), + ); + + @override + Widget build(BuildContext context) => Center( + child: SizedBox( + width: 355, + height: 400, + child: AnimatedScale( + scale: pending > 0 ? 1.04 : 1, + duration: const Duration(milliseconds: 140), + child: Stack( + clipBehavior: Clip.none, + children: shaking + ? [ + _shadow(156, 348, 192, blur: 2), + _shadow(27, 347, 192, blur: 2), + _shadow(47, 320, 220), + _shadow(108, 320, 220), + _at(67.5, 300, 240, 240, const Image(image: _planet)), + ] + : [ + _at( + 48, + 280, + 280, + 280, + const DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Color(0x4D000000), + ), + ), + ), + _at(67.5, 300, 240, 240, const Image(image: _planet)), + // 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다. + _at( + 38, + 246, + 49, + 56.5, + const RotatedBox( + quarterTurns: 2, + child: Image( + image: AssetImage('assets/figma/meteor-b.png'), + ), + ), + ), + _at( + 328, + 287, + 34.1, + 29.5, + const Image(image: AssetImage('assets/figma/meteor-a.png')), + ), + _at( + 230, + 580, + 29.5, + 34.1, + const Image(image: AssetImage('assets/figma/meteor-b.png')), + ), + ], + ), + ), + ), + ); +} + +/// 결과 — 사본 `홈/이클립스 성공`(분홍 원 + 체크) · 실패 변형(회색 원). +/// +/// 원 200 · 안쪽 그림자(흰 30% ↘ · 검정 30% ↖ · 3.3) · 아래 「확인」(343×56 · r8 · 분홍). +class _Outcome extends StatelessWidget { + const _Outcome({ + required this.title, + required this.success, + this.confirm = true, + this.extra = const [], + }); + + final String title; + final bool success; + final bool confirm; + final List extra; + + @override + Widget build(BuildContext context) => Column( + children: [ + const SizedBox(height: 56), + const SizedBox(height: 24), + SizedBox( + height: 68, + child: Text(title, textAlign: TextAlign.center, style: _titleStyle), + ), + const SizedBox(height: 104), + CustomPaint( + size: const Size.square(200), + painter: _OutcomeDisc( + color: success ? AppColors.cosmicAccent : const Color(0xFFBDBDBD), + check: success, + ), + ), + const SizedBox(height: AppSpace.s5), + ...extra, + const Spacer(), + if (confirm) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpace.s4, + 0, + AppSpace.s4, + AppSpace.s4, + ), + child: FilledButton( + onPressed: () => Navigator.of(context).maybePop(), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: Text(context.l10n.commonConfirm), + ), + ), + ], + ); +} + +/// 사본 `Group 567` — 200 원 · 안쪽 그림자 두 겹(흰 30% +3.3 · 검정 30% -3.3, 흐림 3.3) +/// 과 `Vector` 체크(50×33.3 · 선 16.7 #F8F8F8 · 둥근 끝). +class _OutcomeDisc extends CustomPainter { + const _OutcomeDisc({required this.color, required this.check}); + + final Color color; + final bool check; + + @override + void paint(Canvas canvas, Size size) { + final circle = Offset.zero & size; + canvas.drawOval(circle, Paint()..color = color); + canvas.save(); + canvas.clipPath(Path()..addOval(circle)); + for (final (shadow, d) in [(0x4DFFFFFF, 3.3), (0x4D000000, -3.3)]) { + // 원 바깥을 칠한 뒤 d 만큼 밀어 흐리면 안쪽 가장자리에만 그림자가 남는다. + final ring = Path() + ..fillType = PathFillType.evenOdd + ..addRect(circle.inflate(20)) + ..addOval(circle.shift(Offset(d, d))); + canvas.drawPath( + ring, + Paint() + ..color = Color(shadow) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3.3 / 2), + ); + } + canvas.restore(); + if (!check) return; + canvas.drawPath( + Path() + ..moveTo(74.1, 99.2) + ..lineTo(90.8, 115.8) + ..lineTo(124.1, 82.5), + Paint() + ..color = const Color(0xFFF8F8F8) + ..style = PaintingStyle.stroke + ..strokeWidth = 16.7 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(_OutcomeDisc old) => + old.color != color || old.check != check; +} diff --git a/app/lib/features/eclipse/eclipse_repository_firestore.dart b/app/lib/features/eclipse/eclipse_repository_firestore.dart new file mode 100644 index 0000000..f0e48e7 --- /dev/null +++ b/app/lib/features/eclipse/eclipse_repository_firestore.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; + +import '../../core/data/backend.dart'; +import '../../core/data/shared_stream.dart'; +import 'eclipse_data.dart'; + +/// 이클립스의 실 서버 구현. +/// +/// **요약 문서와 샤드를 함께 구독한다.** `fn-eclipse-aggregate` 는 1 분 주기인데 +/// (Cloud Scheduler 최소 주기) 별첨 1 ① 시나리오 5 는 다른 단말의 참여가 내 화면에 +/// 곧 보이기를 요구한다. 그래서 목표·마감 같은 정적 값은 요약 문서에서 읽고, +/// **움직이는 값(방어량 · 참여 수)은 샤드 10 개를 직접 합산한다.** +/// +/// 침식률만은 서버가 적은 값을 그대로 쓴다. 화면이 다시 계산하면 단말마다 다른 +/// 침식률이 뜨고, 그 순간 「방어했는데 수치가 안 맞는다」가 된다. +/// +/// **같은 팬덤·조회 범위의 구독은 공유한다.** HOME 배너·GALAXY 배너·행성 HUD가 +/// 같은 집계를 보므로, 화면마다 `snapshots()` 를 열면 한 팬덤에 리스너가 화면 수만큼 +/// 붙는다. [SharedStream]이 같은 원본을 나눠 주고 방어 화면은 마감 결과까지 별도로 받는다. +class FirestoreEclipseRepository implements EclipseRepository { + FirestoreEclipseRepository(this._backend); + + final Backend _backend; + + /// 내 소속 팬덤. 구독마다 다시 읽으면 화면 수만큼 왕복이 늘어 한 번만 읽는다. + Future? _myFandomId; + + /// 팬덤별 공유 구독. 키가 빈 문자열이면 「내 팬덤」이다. + final _watchers = >{}; + final _tickers = >>{}; + + Future _fandomId(String? given) { + if (given != null && given.isNotEmpty) return Future.value(given); + return _myFandomId ??= _backend.db + .doc('users/${_backend.requireUid}') + .get() + .then((snap) => (snap.data() ?? const {}).strOr('fandomId')); + } + + @override + Stream watch({String? fandomId, bool includeResults = false}) => + (_watchers['${fandomId ?? ''}#$includeResults'] ??= SharedStream( + () => _openWatch(fandomId, includeResults), + )).stream; + + @override + Stream> liveEvents({String? fandomId, int limit = 20}) => + (_tickers['${fandomId ?? ''}#$limit'] ??= SharedStream>( + () => _openTicker(fandomId, limit), + )).stream; + + Stream _openWatch( + String? fandomId, + bool includeResults, + ) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) { + if (id.isEmpty) return Stream.value(null); + return Stream.multi((out) { + StreamSubscription>>? shardsSub; + Timer? expiry; + var generation = 0; + final eventsSub = _backend.db + .collection('fandoms/$id/eclipseRounds') + .where('status', whereIn: includeResults ? ['active', 'ended', 'cancelled'] : ['active']) + .orderBy('startAt', descending: true) + .limit(1) + .snapshots(includeMetadataChanges: true) + .where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites) + .listen((events) { + final current = ++generation; + shardsSub?.cancel(); + expiry?.cancel(); + if (events.docs.isEmpty) { + out.add(null); + return; + } + final doc = events.docs.first; + EclipseState? latest; + final endsAt = doc.data()['endsAt']; + if (endsAt is Timestamp) { + final left = endsAt.toDate().difference(DateTime.now()); + if (left <= Duration.zero && !includeResults) { + out.add(null); + return; + } + if (left > Duration.zero) { + expiry = Timer(left, () { + if (current == generation) out.add(includeResults ? latest : null); + }); + } + } + // asyncExpand는 무한 샤드 스트림이 끝날 때까지 다음 회차/종료를 + // 막는다. 최신 요약이 올 때 이전 샤드 구독을 교체한다. + shardsSub = doc.reference + .collection('shards') + .snapshots(includeMetadataChanges: true) + .where( + (s) => + !s.metadata.isFromCache && !s.metadata.hasPendingWrites, + ) + .listen((shards) { + if (current != generation) return; + final state = _state( + fandomId: id, + eclipseId: doc.id, + data: doc.data(), + shards: shards.docs, + ); + latest = state; + out.add(!includeResults && state.isExpired(DateTime.now()) ? null : state); + }, onError: out.addError); + }, onError: out.addError); + out.onCancel = () async { + generation++; + expiry?.cancel(); + await eventsSub.cancel(); + await shardsSub?.cancel(); + }; + }); + }); + + Stream> _openTicker(String? fandomId, int limit) => + Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) { + if (id.isEmpty) return Stream.value(const []); + return _backend.db + .collection('fandoms/$id/liveEvents') + .orderBy('createdAt', descending: true) + .limit(limit) + .snapshots() + .map( + (snap) => [ + for (final doc in snap.docs) _liveEvent(doc.id, doc.data()), + ], + ); + }); + + @override + Future defend({ + required String fandomId, + required String eclipseId, + required String clientSeq, + required int shakes, + }) async { + final data = await _backend.call('fnEclipseDefend', { + 'fandomId': fandomId, + 'eclipseId': eclipseId, + 'clientSeq': clientSeq, + 'shakes': shakes, + }); + return DefendResult( + accepted: (data['accepted'] as num?)?.toInt() ?? 0, + clamped: (data['clamped'] as num?)?.toInt() ?? 0, + myTotal: (data['myTotal'] as num?)?.toInt() ?? 0, + duplicate: data['duplicate'] == true, + ); + } + + EclipseState _state({ + required String fandomId, + required String eclipseId, + required Map data, + required List>> shards, + }) { + // 샤드 합. 샤드가 아직 없으면 요약 문서의 값으로 물러난다 — 첫 참여 전에는 + // 샤드가 비어 있고, 그때 0 을 그리면 이미 모인 방어가 사라져 보인다. + final live = shards.fold<(int, int)>((0, 0), (acc, doc) { + final d = doc.data(); + return (acc.$1 + d.intOr('qualifiedParticipants'), acc.$2 + d.intOr('defenseCount')); + }); + final endsAt = data['endsAt']; + return EclipseState( + fandomId: fandomId, + eclipseId: eclipseId, + fandomName: data.strOr('fandomName', fandomId), + erosionRate: data.intOr('erosionRate', 100), + defense: shards.isEmpty ? data.intOr('qualifiedParticipants') : live.$1, + // 원장 C-25·C-26: 인정 횟수 이상인 고유 참여자 수 / DAU 기반 목표. + targetDefense: data.intOr('target'), + qualifyShakes: data.intOr('qualifyShakes', 60), + defenseCount: shards.isEmpty ? data.intOr('defenseCount') : live.$2, + defended: data.boolOr('defended') || data['result'] == 'success', + result: switch (data['result']) { + 'success' => EclipseResult.success, + 'failed' => EclipseResult.failure, + 'cancelled_error' => EclipseResult.cancelled, + _ => null, + }, + endsAt: endsAt is Timestamp ? endsAt.toDate() : null, + ); + } + + LiveEvent _liveEvent(String id, Map data) => LiveEvent( + id: id, + // 서버가 닉네임을 비워 보내는 경우가 없도록 막아 두었으나, 옛 문서가 섞일 수 + // 있어 화면 쪽에서도 기본값을 둔다. + // 저장값 「팬」도 지금 언어의 「팬」으로(M-7). + nickname: publicName(data['nickname']), + defense: data.intOr('defense'), + at: data.dateOr('createdAt', DateTime.now()), + kind: LiveEventKind.of(data.strOr('kind')), + priority: data.intOr('priority', 50), + linkTo: data.strOr('linkTo'), + ); +} diff --git a/app/lib/features/eclipse/widgets/eclipse_banner.dart b/app/lib/features/eclipse/widgets/eclipse_banner.dart new file mode 100644 index 0000000..f7c78fc --- /dev/null +++ b/app/lib/features/eclipse/widgets/eclipse_banner.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/live_stream.dart'; +import '../../home/home_screen.dart' show formatPop; +import '../../planet/fpi_ranking_screen.dart' show MockBadge; +import '../eclipse_data.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// 이클립스 인앱 경보 배너 — HOME · GALAXY 상단. +/// +/// 근거: 별첨 1 ① 예외 처리 「**푸시 미수신 단말 — 인앱 워닝 배너 병행**」. 푸시가 +/// 유일한 통보 수단이면 알림을 끈 사용자와 토큰이 만료된 단말은 이클립스를 모른 채 +/// 침식을 맞는다. 푸시(FCM) 자체는 다른 작업 범위이고 이 배너는 그와 **병행**한다. +/// +/// 배너는 세 상태를 그린다. +/// - 활성 이클립스 없음(또는 마감이 지남) → 아무것도 그리지 않는다(빈 [SizedBox]). +/// - 침식 진행 중 → 침식률 · 방어 진행률 · 남은 시간. +/// - 방어 성공(`defended`) → 회복 문구(원장 B-55 「방어 성공 시 침식분 회복」). +/// +/// 방어는 **공동 Shake 참여**이며 POP 을 소모하지 않는다. 눌러 들어가는 곳은 +/// 「흔들어 방어」 화면이다. +class EclipseBanner extends StatelessWidget { + const EclipseBanner({ + super.key, + required this.repository, + this.fandomId, + this.onTap, + this.margin = const EdgeInsets.fromLTRB( + AppSpace.s4, + AppSpace.s2, + AppSpace.s4, + 0, + ), + }); + + final EclipseRepository repository; + + /// 어느 팬덤의 이클립스인가. 주지 않으면 내 소속 팬덤이다. + final String? fandomId; + + final VoidCallback? onTap; + final EdgeInsets margin; + + @override + Widget build(BuildContext context) => LiveStreamBuilder( + // 대상 팬덤이 바뀌면 구독을 갈아탄다 — [LiveStreamBuilder] 주석 참고. + key: ValueKey('eclipse-${fandomId ?? 'mine'}'), + create: () => repository.watch(fandomId: fandomId), + builder: (context, state) => state == null + // 활성 이클립스가 없으면 자리도 차지하지 않는다. 빈 배너를 남겨 두면 + // 홈 상단에 설명 없는 여백이 생긴다. + ? const SizedBox.shrink() + : Padding( + padding: margin, + child: EclipseBannerCard(state: state, onTap: onTap), + ), + ); +} + +/// 배너 본체. 스트림과 떼어 두어 위젯 테스트가 값을 직접 넣을 수 있게 한다. +class EclipseBannerCard extends StatelessWidget { + const EclipseBannerCard({super.key, required this.state, this.onTap}); + + final EclipseState state; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final won = state.defended; + final color = won ? AppColors.neonEmerald : AppColors.neonMagenta; + final left = state.remaining(DateTime.now()); + + return Semantics( + // 배너는 경보다. 스크린리더가 「컨테이너」로 읽으면 경보가 전달되지 않는다. + liveRegion: true, + container: true, + child: Material( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.all(AppSpace.s3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: color.withValues(alpha: 0.7)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + won ? Icons.verified_outlined : Icons.warning_amber_rounded, + size: 18, + color: color, + ), + const SizedBox(width: AppSpace.s2), + Expanded( + child: Text( + won + ? context.l10n.eclipseBannerDefended(state.fandomName) + : context.l10n.eclipseBannerActive( + state.fandomName, + '${state.erosionRate}', + ), + maxLines: 2, + style: t.bodyMedium?.copyWith(color: color), + ), + ), + if (left != null) + Text(_left(left), style: t.labelSmall), + ], + ), + const SizedBox(height: AppSpace.s2), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: state.progress, + minHeight: 4, + backgroundColor: AppColors.cosmicOutline, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + const SizedBox(height: AppSpace.s1), + Text( + // **POP 단위가 아니다** — 방어는 공동 Shake 참여이고 POP 을 + // 소모하지 않는다(설계서 v1.1 §9-#5). + context.l10n.eclipseBannerDefense( + formatPop(state.defense), + formatPop(state.targetDefense), + ), + style: t.labelSmall, + ), + // 실데이터가 아니면 숨기지 않는다 — 대조표 3-5 · 공통 규칙 (4). + if (state.mockNote case final note?) + MockBadge(note: note), + ], + ), + ), + ), + ), + ); + } + + /// 남은 시간 표기. 분 단위까지면 충분하다 — 방어 창은 분 단위 운영값이다. + static String _left(Duration d) => d.inHours > 0 + ? appL10n.timeHoursMinutesLeft('${d.inHours}', '${d.inMinutes % 60}') + : appL10n.timeMinutesLeft('${d.inMinutes}'); +} diff --git a/app/lib/features/eclipse/widgets/live_proof.dart b/app/lib/features/eclipse/widgets/live_proof.dart new file mode 100644 index 0000000..373e2a4 --- /dev/null +++ b/app/lib/features/eclipse/widgets/live_proof.dart @@ -0,0 +1,27 @@ +import '../../home/home_screen.dart' show formatPop; +import '../eclipse_data.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// 티커 한 줄의 문구. +/// +/// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 — +/// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*. +/// 이클립스 방어 참여는 같은 형식에 **참여량**을 싣는다. POP 이 아니다 — 방어는 +/// POP 을 소모하지 않는 공동 Shake 참여다(설계서 v1.1 §9-#5). +/// +/// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을 +/// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다. +/// +/// 별첨 B 「Global Ticker」로 Eclipse 외의 종류가 뒤에 들어오므로 종류마다 한 줄씩 +/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다. +String liveEventMessage(LiveEvent event) => switch (event.kind) { + LiveEventKind.eclipseDefense => + appL10n.liveEclipseDefense(event.nickname, formatPop(event.defense)), + LiveEventKind.artistHotTime => + appL10n.liveHotTime(event.nickname), + LiveEventKind.heroGift => + appL10n.liveHeroGift(event.nickname), + LiveEventKind.campaign => + appL10n.liveCampaign(event.nickname), + LiveEventKind.notice => '[LIVE] ${event.nickname}', +}; diff --git a/app/lib/features/galaxy/galaxy_data.dart b/app/lib/features/galaxy/galaxy_data.dart new file mode 100644 index 0000000..8d2158a --- /dev/null +++ b/app/lib/features/galaxy/galaxy_data.dart @@ -0,0 +1,218 @@ +/// 갤럭시 맵이 그리는 값의 형태. +/// +/// 화면과 데이터를 갈라 둔 이유는 홈과 같다 — 5주차 Firestore 연동 때 +/// 구현체만 갈아 끼운다. +/// +/// 근거는 Figma `03_Wireframe` 「갤럭시맵 화면」 명세다. +/// *데이터 : 3d 갤럭시 — FPI 상세 지수 변동 데이터 · HUD — Rank, FPI 총점, 접속자수(멤버수)* +library; + +/// 맵 위의 행성 하나. +class PlanetNode { + const PlanetNode({ + required this.id, + required this.name, + required this.rank, + required this.fpi, + required this.members, + required this.online, + this.isMine = false, + this.stage, + this.colorKey, + bool? isCenter, + }) : isCenter = isCenter ?? isMine; + + /// Firestore `fandoms/{id}` 문서 ID. HUD 에서 행성 상세로 넘어갈 때 + /// **이 값으로 저장소를 만든다** — 없으면 어느 행성을 눌러도 같은 상세가 뜬다. + final String id; + + /// 팬덤 서버 Stage 0~6; 미제공이면 null. + final int? stage; + + /// 팬덤 `colorKey`(행성 색 팔레트 `planetColors` 의 키). 미제공이면 null. + final String? colorKey; + + final String name; + + /// 갤럭시 전체 순위. HUD 의 `Rank`. + /// + /// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고 + /// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다. + final int rank; + + /// FPI 총점. 산출식은 정책 설계서 §P-03 — 초기 MVP 는 단순 합산이다. + final int fpi; + + final int members; + + /// 접속자 수. 명세의 「접속자수(맴버수)」. + final int online; + + /// 내 행성. 명세 규칙 「행성 — 나의행성 한가운데」. + final bool isMine; + + /// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(원장 B-21). + final bool isCenter; +} + +/// 하단 이벤트 띠에 흐르는 한 줄. +/// +/// 명세 — *이벤트1 아이템 사용(닉네임·아이템명) · 이벤트2 이클립스(행성/성운명)*. +/// 디폴트는 숨김이고 이벤트가 나면 우→좌로 1회 흐른다. +enum GalaxyEventKind { itemUsed, eclipse } + +class GalaxyEvent { + const GalaxyEvent({required this.kind, required this.message}); + final GalaxyEventKind kind; + final String message; +} + +class GalaxySnapshot { + const GalaxySnapshot({ + required this.planets, + required this.myPop, + this.zapPop = 10, + this.event, + this.joined = true, + this.memberships = const [], + }); + + final List planets; + + /// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」). + /// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다. + final List memberships; + + /// 내 활동 POP 잔액. Zap 이 여기서 차감된다. + final int myPop; + + /// ZAP 1 회 기여 POP. `config/policy.zapPop` — Figma 「Zap 버튼」이 + /// 「어드민에서 설정한 기여 POP」이라 못 박은 값이다. + final int zapPop; + + final GalaxyEvent? event; + + /// 행성·성운에 가입했거나 만든 사용자인가. + /// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼). + final bool joined; + + PlanetNode? get mine => + planets.where((p) => p.isMine).firstOrNull; + + /// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데 + /// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로 + /// (B-17) 중심 후보에서 뺀다. 공동 1위면 목록 순서(C-19 고정 정렬키)의 첫 행성이다. + GalaxySnapshot get rankingView { + PlanetNode? top; + for (final p in planets) { + if (p.stage != 0 && (top == null || p.rank < top.rank)) top = p; + } + return GalaxySnapshot( + planets: [ + for (final p in planets) + PlanetNode( + id: p.id, + name: p.name, + rank: p.rank, + fpi: p.fpi, + members: p.members, + online: p.online, + isMine: p.isMine, + stage: p.stage, + isCenter: identical(p, top), + ), + ], + myPop: myPop, + zapPop: zapPop, + event: event, + joined: joined, + memberships: memberships, + ); + } +} + +/// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다. +class ZapResult { + const ZapResult({required this.txId, required this.balanceAfter, this.contributedPop}); + + final String txId; + + /// 서버 차감 lot 합계. 누락되면 요청액으로 추정하지 않는다. + final int? contributedPop; + + /// 차감 후 잔액. **-1 은 「서버가 알려 주지 않았다」** 로, 화면은 직전 값을 지킨다. + final int balanceAfter; +} + +abstract interface class GalaxyRepository { + Future load(); + + /// ZAP — **행성 성장 기여**다. 이클립스 방어가 아니다. + /// + /// 별첨 B **B-23** — *ZAP 차감액은 누적 기여 POP 에 실제 값으로 반영되고, 행성 + /// 성장용 Growth 진행도에만 가중을 둔다(원장·sFPI 에는 가중 미적용)*. Figma + /// 「Zap 버튼」의 *「어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가」* + /// 가 이 경로다. + /// + /// 차감은 `fn-pop-spend` 가 한다 — 화면이 POP 을 직접 깎지 않는다(별첨 1 ②). + /// [count] 는 연타로 묶은 횟수이며 **원장 행에 그대로 남는다** — MY 의 POP 내역이 + /// 「Zap N 회」를 행 수로 세면 실제보다 작게 나온다. + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }); + + /// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면 + /// E-FDM-04 로 거부된다. + Future setPrimary(String fandomId); +} + +/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** +class SampleGalaxyRepository implements GalaxyRepository { + const SampleGalaxyRepository({this.joined = true}); + + final bool joined; + + @override + Future setPrimary(String fandomId) async {} + + /// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다. + @override + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }) async => const ZapResult(txId: '', balanceAfter: -1); + + @override + Future load() async => GalaxySnapshot( + myPop: 12540, + joined: joined, + event: const GalaxyEvent( + kind: GalaxyEventKind.itemUsed, + message: '아미보이님이 메가 드랍을 사용했습니다', + ), + planets: const [ + PlanetNode( + id: 'bts', + name: 'BTS', + rank: 1, + fpi: 284310, + members: 12840, + online: 1204, + isMine: true, + ), + PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980), + PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872), + PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 198740, members: 8320, online: 611), + PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 176250, members: 7640, online: 540), + PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 152080, members: 6910, online: 402), + ], + memberships: const [ + PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 1204, isMine: true), + PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980), + PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872), + ], + ); +} diff --git a/app/lib/features/galaxy/galaxy_home_screen.dart b/app/lib/features/galaxy/galaxy_home_screen.dart new file mode 100644 index 0000000..cddc213 --- /dev/null +++ b/app/lib/features/galaxy/galaxy_home_screen.dart @@ -0,0 +1,447 @@ +import 'package:flutter/material.dart'; +import '../../core/data/planet_visual_state.dart'; + +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; +import '../notification/notification_screen.dart'; +import '../eclipse/eclipse_data.dart'; +import '../eclipse/widgets/eclipse_banner.dart'; +import 'galaxy_data.dart'; +import '../planet/planet_data.dart'; +import 'galaxy_screen.dart'; +import 'galaxy_search_screen.dart'; +import '../home/home_data.dart'; +import '../../core/data/backend.dart' show BackendException; +import '../home/nebula_create_screen.dart'; +import '../planet/planet_detail_screen.dart'; +import '../../core/i18n/app_locale.dart'; + +/// GALAXY 탭 루트 — 시안 `354:220228` Galaxy. +/// +/// 시안은 「내 행성」 카드 그리드다. 카드 109×108 `#2c2c2c` r12, 간격 8, 세 칸. +/// 선택된 행성은 `#ff7ddd` 2px 테두리와 「선택」 핑크 알약이 붙는다. 하단 가운데에 +/// 「성운 생성」 알약(106×36 · `#19191c` · `#232324` 1px)이 뜬다. +/// +/// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라 +/// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다. +class GalaxyHomeScreen extends StatefulWidget { + const GalaxyHomeScreen({ + super.key, + this.watchPlanet = noPlanetWatch, + required this.repository, + required this.planetRepository, + required this.eclipse, + this.notifications = const SampleNotificationRepository(), + this.home = const SampleHomeRepository(), + }); + + final GalaxyRepository repository; + + /// 「성운 생성」 경로(`fn-nebula-create`). + final HomeRepository home; + final PlanetWatch watchPlanet; + + /// 상단 알림 입구가 여는 알림함(F0)의 저장소. + final NotificationRepository notifications; + + /// 궤도 맵을 지나 행성 상세까지 그대로 내려간다. + final PlanetRepositoryFactory planetRepository; + + /// 이클립스 경보·방어. 궤도 맵에도 그대로 넘긴다. + final EclipseRepository eclipse; + + @override + State createState() => _GalaxyHomeScreenState(); +} + +class _GalaxyHomeScreenState extends State { + late Future _snapshot = widget.repository.load(); + + /// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로. + Future _choose(PlanetNode p) async { + if (p.isMine) return _openMap(); + final ok = await showConfirmDialog( + context, + context.l10n.galaxySetPrimaryConfirm, + ); + if (!ok || !mounted) return; + try { + await widget.repository.setPrimary(p.id); + if (mounted) setState(() { _snapshot = widget.repository.load(); }); + } on BackendException catch (e) { + // 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다. + if (mounted) await showAlertDialog(context, e.message); + } + } + + void _openMap() => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => GalaxyScreen( + repository: widget.repository, + watchPlanet: widget.watchPlanet, + planetRepository: widget.planetRepository, + eclipse: widget.eclipse, + ), + ), + ); + + /// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다. + Future _createNebula() async { + final navigator = Navigator.of(context, rootNavigator: true); + final myPop = (await _snapshot).myPop; + final fandomId = await navigator.push( + MaterialPageRoute( + builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop), + ), + ); + if (fandomId == null || !mounted) return; + setState(() { _snapshot = widget.repository.load(); }); + await navigator.push( + MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + nebula: true, + repository: widget.planetRepository(fandomId), + watchPlanet: widget.watchPlanet, + eclipse: widget.eclipse, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Scaffold( + body: SafeArea( + child: FutureBuilder( + future: _snapshot, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final data = snap.data; + if (snap.hasError || data == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.galaxyLoadFailed, style: t.titleMedium), + const SizedBox(height: AppSpace.s4), + FilledButton( + onPressed: () => + setState(() => _snapshot = widget.repository.load()), + child: Text(context.l10n.commonRetry), + ), + ], + ), + ); + } + // 시안은 내 행성 셋을 보여 준다 — 가입하거나 만든 행성/성운, 최신 가입순(§2.58). + // 「선택」 알약은 대표 팬덤(C-15)이다. + final mine = data.memberships.take(3).toList(); + + return Stack( + children: [ + ListView( + padding: const EdgeInsets.only(bottom: AppSpace.s9), + children: [ + // 상단 — 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Row( + children: [ + const Text( + 'GALAXY', + style: TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 20, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + const Spacer(), + _HeaderIcon( + asset: 'assets/figma/icon-search-42.png', + label: context.l10n.commonSearch, + onTap: () => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => GalaxySearchScreen( + repository: widget.repository, + ), + ), + ), + ), + _HeaderIcon( + asset: 'assets/figma/icon-bell-42.png', + label: context.l10n.homeNotificationsTooltip, + onTap: () => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => NotificationScreen( + repository: widget.notifications, + ), + ), + ), + ), + ], + ), + ), + // 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱 + // 워닝 배너 병행」. 대상은 내 행성이다. + if (mine.where((p) => p.isMine).firstOrNull case final my?) + EclipseBanner( + repository: widget.eclipse, + fandomId: my.id, + margin: const EdgeInsets.fromLTRB( + 16, + AppSpace.s3, + 16, + 0, + ), + onTap: _openMap, + ), + // 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + children: [ + Text( + context.l10n.galaxyMyPlanets, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 26, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + const Spacer(), + InkResponse( + onTap: _openMap, + radius: 20, + child: Image.asset( + 'assets/figma/icon-list.png', + width: 24, + height: 24, + color: const Color(0xFF777777), + semanticLabel: context.l10n.galaxyMap, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + for (final (i, p) in mine.indexed) ...[ + if (i > 0) const SizedBox(width: AppSpace.s2), + Expanded( + child: _PlanetCard( + planet: p, + image: _imageFor(i), + selected: p.isMine, + onTap: () => _choose(p), + ), + ), + ], + // 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다. + for (var i = mine.length; i < 3; i++) ...[ + if (i > 0) const SizedBox(width: AppSpace.s2), + const Expanded(child: _EmptySlot()), + ], + ], + ), + ), + ], + ), + // 「성운 생성」 — 시안 하단 가운데 알약 + Positioned( + left: 0, + right: 0, + bottom: 16, + child: Center( + child: _NebulaPill(onTap: _createNebula), + ), + ), + ], + ); + }, + ), + ), + ); + } + + /// 카드 그림. 시안 세 칸(`image 8` · `image 15` · 파란 행성 벡터)을 순서대로 쓴다. + static String _imageFor(int i) => switch (i) { + 0 => 'assets/figma/planet-detail-hero.png', + 1 => 'assets/figma/nebula-card.png', + _ => 'assets/figma/planet-earth.png', + }; +} + +/// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와 +/// 오른쪽 위에 붙은 「선택」 알약(32×24 · 12/600). +class _PlanetCard extends StatelessWidget { + const _PlanetCard({ + required this.planet, + required this.image, + required this.selected, + required this.onTap, + }); + + final PlanetNode planet; + final String image; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => AspectRatio( + aspectRatio: 1, + child: Material( + color: const Color(0xFF2C2C2C), + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + border: selected + ? Border.all(color: AppColors.cosmicAccent, width: 2) + : null, + ), + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.fromLTRB(11, 10, 10, 11), + child: Image.asset( + image, + fit: BoxFit.contain, + semanticLabel: planet.name, + ), + ), + ), + if (selected) + Positioned( + top: 0, + right: 0, + child: Container( + width: 32, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.cosmicAccent, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + context.l10n.commonSelect, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1, + color: AppColors.onCosmic, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); +} + +/// 상단 42 아이콘(시안 `icon` 42×42 — 24 그림 + 여백). +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + required this.asset, + required this.label, + required this.onTap, + }); + + final String asset; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkResponse( + onTap: onTap, + radius: 21, + child: Image.asset(asset, width: 42, height: 42, semanticLabel: label), + ); +} + +class _EmptySlot extends StatelessWidget { + const _EmptySlot(); + + @override + Widget build(BuildContext context) => AspectRatio( + aspectRatio: 109 / 108, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF2C2C2C).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); +} + +class _NebulaPill extends StatelessWidget { + const _NebulaPill({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF19191C), + borderRadius: BorderRadius.circular(AppRadius.full), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.full), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.cosmicSurfaceRaised), + ), + // 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0 + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const ImageIcon( + AppIcons.badge, + size: 16, + color: AppColors.cosmicAccent, + ), + const SizedBox(width: 6), + Text( + context.l10n.nebulaCreate, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 20 / 14, + color: AppColors.onCosmicVariant, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/galaxy/galaxy_motion.dart b/app/lib/features/galaxy/galaxy_motion.dart new file mode 100644 index 0000000..f59ab9d --- /dev/null +++ b/app/lib/features/galaxy/galaxy_motion.dart @@ -0,0 +1,148 @@ +import 'dart:math' as math; +import 'dart:ui' show Offset; + +import 'package:flutter/foundation.dart'; + +/// 갤럭시 공간 인터랙션의 조정 손잡이 — **여기 말고 다른 데에 수치를 흩지 않는다.** +/// +/// 발주사 검토회신서 3절이 요구한 것은 기능이 아니라 체감이다 +/// (*드래그·회전 움직임과 관성 · 행성 간 이동감 · 깊이감*). 체감은 숫자 몇 개를 +/// 돌려 가며 맞추는 일이라, 값이 위젯 안에 박혀 있으면 조정할 수가 없다. +/// +/// 기준값의 출처는 발주사 프로토타입이다 — +/// `FANUZU_HUD Proto.html` 의 `lerp(cur, target, 0.03~0.08)` 감쇠, +/// 행성에 붙은 HUD 스프라이트(`hud.position.y = radius + 5`), +/// `time * 0.8/(rank+1)` 궤도 회전([idleSpin]). +abstract final class GalaxyMotion { + // ── 회전 관성 ───────────────────────────────────────────────────────── + /// 손을 대지 않았을 때 [GalaxyClock.spin] 이 흐르는 속도(rad/s). + /// + /// **행성의 실제 각속도는 이 값이 아니다** — [GalaxyField.positionOf] 가 + /// `spin * orbitSpeedOf(rank)` 를 각도로 쓰므로 + /// `idleSpin × 1.6/(rank+1)` = `0.4/(rank+1)` rad/s 다. + /// 프로토타입 `FANUZU_HUD Proto.html` 이 `time = Date.now()*0.0005`(0.5rad/s)에 + /// `speed = 0.8/(rank+1)` 을 곱해 `0.4/(rank+1)` 을 쓴다 — 같은 값이다. + /// (rank 2 → 0.133rad/s, 1회전 47초.) + static const double idleSpin = 0.25; + + /// 가로 드래그 속도(px/s) → 각속도(rad/s) 환산 계수. + static const double dragSpinGain = 0.0011; + + /// 궤도를 돌리는 드래그 성분만 골라낸다. 나머지는 0 이다. + /// + /// [InteractiveViewer] 는 팬·핀치·회전을 한 콜백으로 준다. `focalPointDelta.dx` + /// 를 그대로 넘기면 **세로 드래그와 핀치 줌이 각속도를 덮어써** 궤도가 선다 + /// ([GalaxyClock.advance] 는 드래그가 있는 프레임에 감쇠를 건너뛴다). + static double spinDragOf(int pointerCount, Offset delta) => + pointerCount == 1 && delta.dx.abs() > delta.dy.abs() ? delta.dx : 0; + + /// 각속도가 기본 표류로 돌아가는 감쇠율(1/s). 클수록 빨리 선다. + /// e-폴딩 시간 = 1/이 값 ≈ 0.56초. + static const double spinFriction = 1.8; + + /// 각속도 상한(rad/s). 없으면 세게 던졌을 때 궤도가 눈으로 못 따라간다. + static const double maxSpin = 2.0; + + /// 링 맥동 한 주기(초). + static const double pulsePeriod = 2.4; + + // ── 줌 ──────────────────────────────────────────────────────────────── + static const double minZoom = 0.55; + static const double maxZoom = 3.2; + + /// 행성을 고르면 이 배율로 다가간다. + static const double focusZoom = 1.5; + + // ── 중심 전환 ───────────────────────────────────────────────────────── + /// 「행성 터치시 화면 중심 전환이동」에 드는 시간. + static const Duration recenter = Duration(milliseconds: 620); + + // ── HUD ─────────────────────────────────────────────────────────────── + /// HUD 카드 폭(논리 px). + static const double hudWidth = 214; + + /// 행성 표면과 HUD 카드 사이 거리 — 둘을 잇는 선의 길이이기도 하다. + /// 프로토타입 `hud.position.y = radius + 5` 의 `proximityOffset` 자리다. + static const double hudGap = 18; + + /// 위/아래를 정할 때 쓰는 HUD 높이 어림값. 실제 높이는 내용이 정한다. + /// Figma 명세가 *「HUD - 행성 상단/하단」* 이라 둘 다 정상 배치다. + static const double hudHeightHint = 96; + + /// HUD 가 화면 가장자리에 붙지 않도록 남기는 여백. + static const double hudMargin = 12; + + // ── 깊이감 ──────────────────────────────────────────────────────────── + /// 궤도 타원의 세로 눌림. 1 이면 정원(위에서 수직으로 본 것), 작을수록 눕는다. + static const double tilt = 0.62; + + /// 앞뒤 크기 차. 궤도 앞쪽(화면 아래)이 `1+이 값`, 뒤쪽이 `1-이 값` 배. + static const double depthScale = 0.26; + + /// 뒤쪽 행성이 옅어지는 정도. 0 이면 앞뒤가 같은 진하기다. + static const double depthFade = 0.34; + + /// 선택 링의 반지름 배율. **1.6 보다 커야 한다** — 행성 일러스트가 반지름의 + /// 1.6배까지 그려지므로 그 안쪽에 그리면 그림에 가려 보이지 않는다. + static const double selectionRing = 1.95; + + /// 별밭이 맵을 따라오는 비율. 0 이면 완전히 고정, 1 이면 맵과 한 몸이다. + static const double parallax = 0.12; + + /// 별밭이 따라오는 최대 거리(px). 넘어가면 별밭이 화면 밖으로 빠진다. + static const double parallaxLimit = 160; + + /// 줌이 별밭에 먹히는 비율. 행성보다 덜 커져야 멀리 있어 보인다. + static const double parallaxZoom = 0.25; + + /// 궤도 각속도 배율 — 바깥 궤도일수록 느리다. + /// 프로토타입 `const speed = 0.8 / (data.rank + 1)` 와 같은 꼴이다. + static double orbitSpeedOf(int rank) => 1.6 / (rank + 1); +} + +/// 갤럭시의 시간 — 궤도 각도와 맥동을 한 곳에서 굴린다. +/// +/// [AnimationController.repeat] 를 쓰지 않는 이유는 **각속도가 상수가 아니기 +/// 때문**이다. 드래그가 각속도를 밀어 올리고([drag]), 손을 떼면 마찰이 기본 +/// 표류로 되돌린다([advance]) — 이것이 검토회신서의 *「자연스러운 관성과 감속」*이다. +class GalaxyClock extends ChangeNotifier { + /// 궤도 회전 각도(rad). 단조 증가가 아니다 — 반대로 던지면 줄어든다. + double spin = 0; + + /// 0~1 을 반복하는 맥동 위상. + double pulse = 0; + + double _velocity = GalaxyMotion.idleSpin; + + /// 선택 행성의 서버 상태가 정하는 주변 궤도 표류. 드래그는 그대로 유지한다. + double idleSpeedScale = 1; + + /// 이번 프레임에 쌓인 가로 드래그 거리(px). + double _dragged = 0; + + @visibleForTesting + double get velocity => _velocity; + + /// 드래그가 각속도를 민다. 프레임당 여러 번 들어올 수 있으므로 모아 둔다. + void drag(double dx) => _dragged += dx; + + /// [dt] 초만큼 시간을 흘린다. + void advance(double dt) { + if (dt <= 0) return; + if (_dragged != 0) { + // 손이 닿아 있는 동안의 각속도는 **드래그 거리가 아니라 드래그 속도**를 + // 따른다. 거리로 누적하면 천천히 오래 끌기만 해도 팽이처럼 돌아 버린다. + _velocity = (_dragged / dt * GalaxyMotion.dragSpinGain) + .clamp(-GalaxyMotion.maxSpin, GalaxyMotion.maxSpin); + _dragged = 0; + } else { + // 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다. + final decay = math.exp(-GalaxyMotion.spinFriction * dt); + final idle = GalaxyMotion.idleSpin * idleSpeedScale; + _velocity = idle + (_velocity - idle) * decay; + } + spin += _velocity * dt; + pulse = (pulse + dt / GalaxyMotion.pulsePeriod) % 1.0; + notifyListeners(); + } +} diff --git a/app/lib/features/galaxy/galaxy_repository_firestore.dart b/app/lib/features/galaxy/galaxy_repository_firestore.dart new file mode 100644 index 0000000..fac3214 --- /dev/null +++ b/app/lib/features/galaxy/galaxy_repository_firestore.dart @@ -0,0 +1,154 @@ +import 'package:cloud_firestore/cloud_firestore.dart' show QueryDocumentSnapshot; + +import '../../core/data/backend.dart'; +import '../planet/planet_data.dart' show rankWithTies; +import 'galaxy_data.dart'; + +/// 갤럭시 맵의 실 서버 구현 — 화면 대조 원장 F2. +/// +/// 순위는 앱에서 정렬하지 않고 서버의 `fpiScore` 내림차순을 그대로 받는다. +/// 클라이언트가 순위를 다시 매기면 화면마다 값이 갈린다. +class FirestoreGalaxyRepository implements GalaxyRepository { + FirestoreGalaxyRepository(this._backend); + + final Backend _backend; + + /// 한 화면에 띄우는 행성 수. 더 늘리면 궤도가 겹쳐 읽히지 않는다. + static const int visibleCount = 12; + + @override + Future load() async { + final uid = _backend.requireUid; + final db = _backend.db; + + final userSnap = await db.doc('users/$uid').get(); + final user = userSnap.data() ?? const {}; + // 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다. + final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId')); + // 「가입 또는 생성한 행성/성운」 — 최신 가입순. 시안 그리드가 세 칸이라 셋만 읽는다. + // ponytail: 모아보기 리스트 그리드(명세 §2.58-2)를 붙이면 limit 과 페이지를 연다. + final membershipsF = db + .collection('users/$uid/memberships') + .orderBy('joinedAt', descending: true) + .limit(3) + .get(); + + final fandomsF = db + .collection('fandoms') + .orderBy('fpiScore', descending: true) + .limit(visibleCount) + .get(); + final walletF = db.doc('users/$uid/wallet/main').get(); + final eventF = db + .collection('galaxyEvents') + .orderBy('createdAt', descending: true) + .limit(1) + .get(); + + // ZAP 단가는 운영값이다(Figma 「Zap 버튼」 = 어드민 설정). 로그인 전에도 읽을 수 + // 있게 `firestore.rules` 가 `config/{id}` 를 열어 두었다. + final policyF = db.doc('config/policy').get(); + + final fandoms = (await fandomsF).docs; + final wallet = (await walletF).data() ?? const {}; + final events = (await eventF).docs; + final policy = (await policyF).data() ?? const {}; + + // 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는 + // fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다. + // Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의 + // 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다. + bool isNebula(QueryDocumentSnapshot> d) => d.data()['stage'] == 0; + final formal = fandoms.where((d) => !isNebula(d)).toList(); + final planets = [ + for (final (doc, rank) in rankWithTies( + formal, + (d) => d.data()['fpiScore'] is num ? d.data()['fpiScore'] as num : 0, + (d) => d.id, + )) + _node(doc.id, doc.data(), rank, myFandomId), + for (final doc in fandoms.where(isNebula)) + _node(doc.id, doc.data(), formal.length + 1, myFandomId), + ]; + final byId = {for (final p in planets) p.id: p}; + final membershipIds = [for (final d in (await membershipsF).docs) d.id]; + // 맵(FPI 상위 12)에 없는 가입 팬덤만 따로 읽는다. + final missing = await Future.wait([ + for (final id in membershipIds.where((id) => !byId.containsKey(id))) + db.doc('fandoms/$id').get(), + ]); + for (final doc in missing) { + final data = doc.data(); + if (data != null) byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId); + } + + return GalaxySnapshot( + planets: planets, + myPop: wallet.intOr('activityBalance'), + zapPop: policy.intOr('zapPop', 10), + event: events.isEmpty ? null : _event(events.first.data()), + // Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙). + joined: myFandomId.isNotEmpty, + memberships: [for (final id in membershipIds) ?byId[id]], + ); + } + + PlanetNode _node(String id, Map data, int rank, String myFandomId) => PlanetNode( + id: id, + stage: switch (data['stage']) { + int n when n >= 0 && n <= 6 => n, + _ => null, + }, + colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, + name: data.strOr('name', id), + rank: rank, + fpi: data.intOr('fpiScore'), + members: data.intOr('memberCount'), + online: data.intOr('onlineCount'), + isMine: id == myFandomId, + ); + + @override + Future setPrimary(String fandomId) => + _backend.call('fnPrimaryFandomSet', {'fandomId': fandomId}); + + /// ZAP — 행성 성장 기여(B-23). 차감은 `fn-pop-spend` 가 한다. + /// + /// `count` 를 함께 보내 **원장 한 행에 묶음 횟수를 남긴다**. 연타를 한 호출로 + /// 묶으면 `amount` 만으로는 횟수를 복원할 수 없어, MY 의 「Zap N 회」가 행 수로 + /// 계산되어 실제보다 작게 나온다. + @override + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }) async { + final snapshot = await _backend.db.doc('config/policy').get(); + final zapPop = (snapshot.data() ?? const {}).intOr('zapPop', 10); + final data = await _backend.call('fnPopSpend', { + 'popType': 'activity', + 'amount': zapPop * count, + 'count': count, + 'reasonCode': 'ZAP_CONTRIBUTION', + 'idempotencyKey': 'zap:$fandomId:$clientSeq', + 'fandomId': fandomId, + 'refPath': 'fandoms/$fandomId', + }); + final lots = data['lotRefs']; + final contributedPop = lots is List && lots.isNotEmpty && lots.every( + (lot) => lot is Map && lot['delta'] is int && (lot['delta'] as int) < 0, + ) ? lots.fold(0, (sum, lot) => sum - (lot['delta'] as int)) : null; + return ZapResult( + contributedPop: contributedPop, + txId: data['txId'] as String? ?? '', + balanceAfter: (data['balanceAfter'] as num?)?.toInt() ?? -1, + ); + } + + GalaxyEvent _event(Map data) => GalaxyEvent( + kind: data.strOr('kind') == 'eclipse' + ? GalaxyEventKind.eclipse + : GalaxyEventKind.itemUsed, + message: data.strOr('message'), + ); +} diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart index 8317782..4a7aa93 100644 --- a/app/lib/features/galaxy/galaxy_screen.dart +++ b/app/lib/features/galaxy/galaxy_screen.dart @@ -1,15 +1,1091 @@ +import '../planet/planet_history_screen.dart'; +import '../planet/hero_participation_sheet.dart'; +import '../../core/ui/hero_time_status.dart'; +import '../../core/ui/planet_fx_scope.dart'; +import '../../core/ui/live_planet.dart'; +import 'zap_controller.dart'; + import 'package:flutter/material.dart'; +import '../../core/data/planet_visual_state.dart'; +import 'package:flutter/scheduler.dart'; -import '../../core/ui/tab_placeholder.dart'; +import '../../core/theme/tokens.dart'; +import '../../core/ui/live_stream.dart'; +import '../eclipse/eclipse_data.dart'; +import '../eclipse/eclipse_defend_screen.dart'; +import '../eclipse/widgets/eclipse_banner.dart'; +import '../eclipse/widgets/live_proof.dart'; +import '../../core/format.dart'; +import 'galaxy_data.dart'; +import '../planet/planet_data.dart'; +import 'galaxy_motion.dart'; +import '../planet/planet_detail_screen.dart'; +import 'galaxy_search_screen.dart'; +import 'widgets/galaxy_field.dart'; +import 'widgets/zap_button.dart'; +import 'widgets/zap_feedback.dart'; +import '../../core/i18n/app_locale.dart'; -/// GALAXY 탭 루트 — S-G-PLANET 내 행성. -class GalaxyScreen extends StatelessWidget { - const GalaxyScreen({super.key}); +/// GALAXY 탭 루트 — Figma `03_Wireframe` 「갤럭시맵 화면」(`354:220228`). +/// +/// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다. +/// +/// 명세대로 갖춘 것 +/// - 3d갤럭시 브라우징 줌인·줌아웃, 터치 이동 → [InteractiveViewer] +/// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동 +/// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로 +/// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면 +/// - Zap 하단 고정, 가입·생성한 사용자에게만 노출 +/// - 이벤트 메시지 우→좌 1회 슬라이드 +/// +/// 2026-09-09 검토회신서 3절이 지적한 것과 그 답 +/// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로 +/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다. 이동(팬)의 +/// 관성은 [InteractiveViewer] 가 이미 가진 플링을 그대로 쓴다. +/// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라 +/// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시 +/// 잡으므로 도는 행성도 정확히 잡는다. +/// - *선택 행성 정보가 화면 최상단 고정 패널이다* → HUD 를 행성에 붙였다 +/// ([_PlanetHud]). Figma 명세도 *「HUD - 행성 상단/하단」*이다. +/// - *깊이감이 부족하다* → 별밭 시차·깊이 축척·대기 원근·깊이 순 겹침 +/// ([GalaxyField] · [GalaxyStarfield]). +/// +/// 별첨B 「GALAXY 중심」 확정값은 *기본은 내 Primary 행성/성운 중심, Ranking View 는 +/// FPI 상위 중심*이다. 진입은 내 행성 중심이고, 우측 하단 Ranking View 스위치를 켜면 +/// FPI 1위 행성으로 중심이 옮겨 간다([GalaxySnapshot.rankingView]). +class GalaxyScreen extends StatefulWidget { + const GalaxyScreen({ + super.key, + this.watchPlanet = noPlanetWatch, + required this.repository, + required this.planetRepository, + required this.eclipse, + }); + + final GalaxyRepository repository; + final PlanetWatch watchPlanet; + + /// HUD 를 눌러 들어가는 행성 상세의 저장소. **선택된 행성의 ID 로 만든다** — + /// 고정 저장소를 쓰면 어느 행성을 눌러도 같은 수치가 떠서, 갤럭시 HUD 의 FPI 와 + /// 상세의 TOTAL FPI 가 어긋난다(발주사 검토회신서 3·4절). + final PlanetRepositoryFactory planetRepository; + + /// 공동 Shake 방어 구독. ZAP은 GalaxyRepository의 기여 원장 경로만 쓴다. + final EclipseRepository eclipse; @override - Widget build(BuildContext context) => const TabPlaceholder( - tab: 'GALAXY', - screenId: 'S-G-PLANET', - screenName: '내 행성', + State createState() => _GalaxyScreenState(); +} + +class _GalaxyScreenState extends State + with TickerProviderStateMixin { + late Future _snapshot; + + final _clock = GalaxyClock(); + final _view = TransformationController(); + late final Ticker _ticker; + + /// 중심 전환 — 0→1 을 [GalaxyMotion.recenter] 동안 흐른다. + late final AnimationController _fly; + + Duration _lastTick = Duration.zero; + Matrix4 _flyFrom = Matrix4.identity(); + + PlanetNode? _selected; + int _selectedIndex = 0; + + /// Ranking View(원장 B-21). 끄면 기본 보기 — 내 행성 중심. + bool _ranking = false; + + /// 보기를 바꾸면 **새 중심 행성으로 날아간다** — 「Ranking View 전환 시 FPI 상위 중심으로 + /// 이동」(B-21 검증 기준). 이전 선택은 이전 보기의 좌표라 버린다. + void _toggleRanking(GalaxySnapshot base) { + setState(() { + _ranking = !_ranking; + _selected = null; + }); + final view = _ranking ? base.rankingView : base; + final i = view.planets.indexWhere((p) => p.isCenter); + if (i >= 0 && !_viewport.isEmpty) _center(view.planets[i], i, _viewport); + } + + /// 맵의 가장 낮은 순위 — 궤도 간격이 여기서 나온다([GalaxyField.ringOf]). + int _outerRank = 1; + Size _viewport = Size.zero; + + late final ZapController _zap; + + void _onZapChanged() => setState(() {}); + + @override + void initState() { + super.initState(); + _snapshot = widget.repository.load(); + _zap = ZapController(widget.repository, onFailure: _notify)..addListener(_onZapChanged); + _fly = AnimationController(vsync: this, duration: GalaxyMotion.recenter) + ..addListener(_onFly); + _ticker = createTicker(_tick); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.disableAnimationsOf(context)) { + _ticker.stop(); + if (_fly.isAnimating) { + _fly.stop(); + if (_selected != null && !_viewport.isEmpty) { + _view.value = _targetFor(_selected!, _selectedIndex, _viewport); + } + } + } else if (!_ticker.isActive) { + _lastTick = Duration.zero; + _ticker.start(); + } + } + + @override + void dispose() { + _zap.dispose(); + _ticker.dispose(); + _fly.dispose(); + _clock.dispose(); + _view.dispose(); + super.dispose(); + } + + void _notify(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + void _tick(Duration now) { + final dt = + (now - _lastTick).inMicroseconds / Duration.microsecondsPerSecond; + _lastTick = now; + // 프레임을 오래 건너뛴 뒤(백그라운드 복귀 등) 한 번에 확 도는 것을 막는다. + _clock.advance(dt.clamp(0.0, 1 / 20)); + } + + /// 선택 행성을 화면 한가운데로 데려오는 변환. + Matrix4 _targetFor(PlanetNode p, int index, Size viewport) { + final at = GalaxyField.positionOf(p, index, _clock.spin, _outerRank); + const s = GalaxyMotion.focusZoom; + return Matrix4.identity() + ..translateByDouble( + viewport.width / 2 - at.dx * s, + viewport.height / 2 - at.dy * s, + 0, + 1, + ) + ..scaleByDouble(s, s, 1, 1); + } + + void _onFly() { + final p = _selected; + if (p == null || _viewport.isEmpty) return; + // 목표를 시작할 때 한 번만 잡으면, 날아가는 동안 궤도를 돈 행성은 도착 + // 지점에 없다. 매 프레임 다시 잡아야 「행성 간 이동감」이 어긋나지 않는다. + _view.value = Matrix4Tween( + begin: _flyFrom, + end: _targetFor(p, _selectedIndex, _viewport), + ).transform(Curves.easeOutCubic.transform(_fly.value)); + } + + /// 명세 — *행성 터치시 화면 중심 전환이동*. + void _center(PlanetNode p, int index, Size viewport, {bool animate = true}) { + _viewport = viewport; + setState(() { + _selected = p; + _selectedIndex = index; + }); + if (!animate || MediaQuery.disableAnimationsOf(context)) { + _view.value = _targetFor(p, index, viewport); + return; + } + _flyFrom = _view.value.clone(); + _fly.forward(from: 0); + } + + @override + Widget build(BuildContext context) => PlanetFxScope( + child: Scaffold( + body: SafeArea( + child: FutureBuilder( + future: _snapshot, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError || snap.data == null) { + return Center(child: Text(context.l10n.galaxyLoadFailed)); + } + // 궤도 간격은 행성 수가 정한다. _targetFor 는 목록을 들고 있지 않으므로 + // 스냅샷이 도착한 여기서 한 번 잡아 둔다. + _outerRank = GalaxyField.outerRankOf(snap.data!.planets); + final data = _ranking ? snap.data!.rankingView : snap.data!; + final myFandomId = data.mine?.id; + // 이클립스는 **화면당 한 번만 구독한다.** 배너·HUD·ZAP 버튼이 각자 + // 구독하면 같은 집계를 세 번 읽고, 세 값이 프레임 단위로 어긋난다. + return LiveStreamBuilder( + key: ValueKey('eclipse-${myFandomId ?? 'mine'}'), + create: () => widget.eclipse.watch(fandomId: myFandomId), + builder: (context, eclipse) => LiveStreamBuilder( + key: ValueKey(('galaxy-orbit', (_selected ?? data.mine)?.id)), + create: () => widget.watchPlanet((_selected ?? data.mine)?.id ?? ''), + builder: (context, state) { + _clock.idleSpeedScale = state?.isHibernating == true + ? 0.25 + : state?.isEclipsed == true + ? 0.5 + : state?.isHeroTime == true + ? 1.5 + : 1; + return _Map( + data: data, + watchPlanet: widget.watchPlanet, + eclipse: eclipse, + eclipseRepository: widget.eclipse, + repository: widget.repository, + planetRepository: widget.planetRepository, + clock: _clock, + view: _view, + selected: _selected ?? data.mine, + selectedIndex: _selected == null + ? data.planets.indexWhere((p) => p.isMine).clamp(0, 1 << 30) + : _selectedIndex, + balance: _zap.balance, + pending: _zap.pending, + sending: _zap.sending, + touchSerial: _zap.touchSerial, + zapResult: _zap.result, + retryZap: _zap.needsRetry ? _zap.retry : null, + outerRank: _outerRank, + ranking: _ranking, + onToggleRanking: () => _toggleRanking(snap.data!), + fly: _fly, + onSelect: _center, + onDrag: _clock.drag, + onGrab: () { + // 날아가는 도중에 손을 대면 사용자가 이긴다. + if (_fly.isAnimating) _fly.stop(); + }, + onZap: _zap.tap, + ); + }, + ), + ); + }, + ), + ), + ), ); } + +class _Map extends StatelessWidget { + final PlanetWatch watchPlanet; + const _Map({ + required this.watchPlanet, + required this.data, + required this.eclipse, + required this.eclipseRepository, + required this.repository, + required this.planetRepository, + required this.clock, + required this.view, + required this.selected, + required this.selectedIndex, + required this.balance, + required this.pending, + required this.sending, + required this.touchSerial, + required this.zapResult, + required this.retryZap, + required this.outerRank, + required this.ranking, + required this.onToggleRanking, + required this.fly, + required this.onSelect, + required this.onDrag, + required this.onGrab, + required this.onZap, + }); + + final GalaxySnapshot data; + + /// 활성 이클립스. null 이면 발동 중이 아니라 ZAP 을 보낼 곳이 없다. + final EclipseState? eclipse; + + /// 라이브 소셜 프루프 구독에 쓴다. 방어 호출은 [onZap] 이 맡는다. + final EclipseRepository eclipseRepository; + + final GalaxyRepository repository; + final PlanetRepositoryFactory planetRepository; + final GalaxyClock clock; + final TransformationController view; + final PlanetNode? selected; + final int selectedIndex; + + /// 서버가 확정한 잔액. null 이면 스냅샷 값을 쓴다. + final int? balance; + + /// 아직 서버에 보내지 않은 연타 수. 표시 잔액에서만 미리 뺀다. + final int pending; + final bool sending; + final int touchSerial; + final ZapResult? zapResult; + final VoidCallback? retryZap; + + /// 맵의 가장 낮은 순위 — 궤도 간격을 정한다. 매 프레임 도는 [AnimatedBuilder] + /// 안에서 다시 세지 않도록 위에서 한 번 잡아 내려받는다. + final int outerRank; + + /// Ranking View 켜짐 여부와 스위치. + final bool ranking; + final VoidCallback onToggleRanking; + + /// 중심 전환의 진행. HUD 가 비행 중인지 알아야 위/아래를 붙들 수 있다. + final Animation fly; + final void Function(PlanetNode, int, Size, {bool animate}) onSelect; + final ValueChanged onDrag; + final VoidCallback onGrab; + final ValueChanged onZap; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, box) { + final viewport = Size(box.maxWidth, box.maxHeight); + // 명세 「나의 행성 한가운데」 — 첫 레이아웃에서 한 번 맞춘다. 사용자가 + // 움직인 뒤(항등 행렬이 아님)에는 손대지 않는다. 첫 진입은 날아갈 곳이 + // 없으므로 애니메이션 없이 바로 앉힌다. + if (view.value == Matrix4.identity()) { + final mine = data.planets.indexWhere((p) => p.isMine); + if (mine >= 0) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => onSelect(data.planets[mine], mine, viewport, animate: false), + ); + } + } + final fpiMax = data.planets.fold(1, (m, p) => p.fpi > m ? p.fpi : m); + + return Stack( + children: [ + // 별밭은 맵보다 덜 움직인다 — 시차가 공간의 깊이를 만든다. + Positioned.fill( + child: AnimatedBuilder( + animation: view, + builder: (context, _) => LivePlanetLighting( + fandomId: selected?.id ?? data.mine?.id ?? '', + watch: watchPlanet, + child: GalaxyStarfield(view: view.value), + ), + ), + ), + + Positioned.fill( + child: InteractiveViewer( + transformationController: view, + // 맵은 화면보다 크다(900×900). 기본값(constrained)이면 자식이 + // 화면 폭으로 눌려 오른쪽이 잘리고 내 행성이 가장자리에 걸린다. + constrained: false, + minScale: GalaxyMotion.minZoom, + maxScale: GalaxyMotion.maxZoom, + boundaryMargin: const EdgeInsets.all(240), + // 이동(팬)의 관성은 InteractiveViewer 의 플링이 이미 준다. + // 여기서 더하는 것은 **회전 관성**이다 — 가로로 끌면 궤도가 따라 + // 돌고, 손을 떼면 마찰이 기본 표류로 되돌린다. + onInteractionStart: (_) => onGrab(), + // 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다. + onInteractionUpdate: (d) => onDrag( + GalaxyMotion.spinDragOf(d.pointerCount, d.focalPointDelta), + ), + child: AnimatedBuilder( + animation: clock, + builder: (context, _) => GalaxyField( + onOpenHistory: (planet) => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PlanetHistoryScreen(repository: planetRepository(planet.id), fandomName: planet.name), + )), + planets: data.planets, + watchPlanet: watchPlanet, + selected: selected, + spin: clock.spin, + pulse: clock.pulse, + onSelect: (p) => + onSelect(p, data.planets.indexOf(p), viewport), + ), + ), + ), + ), + + // HUD 는 화면이 아니라 **행성**에 붙는다. 검토회신서 3절 — + // *「선택 행성과 해당 정보가 시각적으로 하나의 정보군으로 인지될 것」*. + if (selected != null) + Positioned.fill( + child: AnimatedBuilder( + animation: Listenable.merge([view, clock]), + // 카드 내용은 선택이 바뀔 때만 달라진다. child 로 넘겨 두면 + // 맵이 움직이는 매 프레임 카드를 다시 짓지 않는다. + child: _HudCard( + planet: selected!, + watchPlanet: watchPlanet, + fpiMax: fpiMax, + planetRepository: planetRepository, + // 구독한 이클립스가 **이 행성의 것일 때만** 침식을 띄운다. + // 다른 행성을 골랐을 때 내 팬덤의 침식률을 그리면 그 행성이 + // 침식되고 있다고 읽힌다. + eclipse: eclipse?.fandomId == selected!.id ? eclipse : null, + eclipseRepository: eclipseRepository, + ), + builder: (context, card) => _PlanetHud( + planet: selected!, + index: selectedIndex, + spin: clock.spin, + outerRank: outerRank, + view: view.value, + viewport: viewport, + flying: fly.isAnimating, + card: card!, + ), + ), + ), + + // 섬광 배너 — `fandoms/{id}/liveEvents` 를 구독해 다른 사용자의 방어 + // 참여를 흘린다(별첨 1 ① 시나리오 5). 라이브 이벤트가 없으면 기존 + // `galaxyEvents` 한 건으로 물러난다. + Positioned( + bottom: data.joined ? 144 : AppSpace.s4, + left: 0, + right: 0, + child: _LiveFlash( + repository: eclipseRepository, + fandomId: eclipse?.fandomId ?? data.mine?.id, + fallback: data.event, + ), + ), + + // 명세 — GALAXY 텍스트는 좌측 하단, 돋보기는 우측 하단. + Positioned( + left: AppSpace.s4, + bottom: AppSpace.s4, + child: Text( + 'GALAXY', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + // Ranking View 스위치 — 사본에 그림이 없어 돋보기와 같은 버튼을 옆에 둔다. + Positioned( + right: AppSpace.s4 + 48 + AppSpace.s2, + bottom: AppSpace.s4, + child: IconButton.filledTonal( + tooltip: 'Ranking View', + isSelected: ranking, + onPressed: onToggleRanking, + icon: const Icon(Icons.leaderboard_outlined), + selectedIcon: const Icon(Icons.leaderboard), + ), + ), + Positioned( + right: AppSpace.s4, + bottom: AppSpace.s4, + child: IconButton.filledTonal( + tooltip: context.l10n.commonSearch, + onPressed: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => GalaxySearchScreen(repository: repository), + ), + ), + icon: const Icon(Icons.search), + ), + ), + + // 진행 중 이벤트를 상단에 모아 서로 겹치지 않게 한다. + Positioned( + top: AppSpace.s2, + left: AppSpace.s4, + right: AppSpace.s4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (eclipse case final e?) + EclipseBannerCard( + state: e, + onTap: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => EclipseDefendScreen( + repository: eclipseRepository, fandomId: e.fandomId, + )), + ), + ), + if (selected case final target?) + HeroTimeBillboard( + fandomId: target.id, + fandomName: target.name, + watch: watchPlanet, + onOpen: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => PlanetDetailScreen( + repository: planetRepository(target.id), + watchPlanet: watchPlanet, + eclipse: eclipseRepository, + )), + ), + ), + ], + ), + ), + + if (data.mine case final mine?) + Positioned.fill( + child: IgnorePointer( + child: AnimatedBuilder( + animation: Listenable.merge([view, clock]), + builder: (context, _) => ZapFeedback( + touchSerial: touchSerial, + pending: pending > 0 || sending, + result: zapResult, + source: Offset( + viewport.width / 2, + viewport.height - + (AppSpace.s4 + 40 + AppSpace.s3) - + AppSizing.touchMinApp / 2, + ), + target: MatrixUtils.transformPoint( + view.value, + GalaxyField.positionOf( + mine, + data.planets.indexOf(mine), + clock.spin, + outerRank, + ), + ), + radius: + GalaxyField.radiusOf(mine) * + view.value.getMaxScaleOnAxis(), + ), + ), + ), + ), + if (retryZap != null) + Positioned( + left: AppSpace.s4, + right: AppSpace.s4, + bottom: 200, + child: Center( + child: FilledButton.tonal( + onPressed: sending ? null : retryZap, + child: Text(context.l10n.commonRecheck), + ), + ), + ), + + // 가입·생성한 사용자에게만 Zap 을 준다. + if (data.joined) + Positioned( + left: AppSpace.s4, + right: AppSpace.s4, + // 하단의 GALAXY 라벨·돋보기(40) 위에 놓는다. + bottom: AppSpace.s4 + 40 + AppSpace.s3, + child: LiveStreamBuilder( + key: ValueKey(('zap-mode', data.mine?.id)), + create: () => watchPlanet(data.mine?.id ?? ''), + builder: (context, state) { + final hero = state?.isHeroTime == true && + state?.heroEndsAt?.isAfter(DateTime.now()) == true && + pending == 0 && !sending && retryZap == null; + return ZapButton( + isHeroTime: hero, + // 단가는 `config/policy.zapPop` 이다 — Figma 「Zap 버튼」이 + // 어드민 설정값이라 못 박았다. + zapPop: data.zapPop, + // 표시 잔액 = 서버 확정값(없으면 스냅샷) − 아직 안 보낸 연타분. + myPop: (balance ?? data.myPop) - pending * data.zapPop, + // **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는 + // 「흔들어 방어」 화면에서 POP 없이 참여한다. + blockedReason: sending + ? context.l10n.zapApplying + : retryZap != null + ? context.l10n.zapNeedsCheck + : data.mine == null + ? context.l10n.zapNoPlanet + : null, + onZap: () { + if (data.mine case final mine?) { + if (hero) { + showModalBottomSheet( + context: context, isScrollControlled: true, + builder: (_) => HeroParticipationSheet( + fandomId: mine.id, watch: watchPlanet, + repository: planetRepository(mine.id), + ), + ); + return; + } + onSelect( + mine, + data.planets.indexOf(mine), + viewport, + animate: !MediaQuery.disableAnimationsOf(context), + ); + onZap(mine.id); + } + }, + onContribute: () => _todo(context, context.l10n.commonContribute), + onItem: () => _todo(context, context.l10n.commonItem), + ); + }, + ), + ), + ], + ); + }, + ); +} + +/// 선택 행성에 붙어 다니는 HUD — 명세 *Rank, FPI 총점, 접속자수(맴버수)* · +/// *HUD - 행성 상단/하단* · *터치시 FPI 상세 페이지 이동*. +/// +/// 발주사 프로토타입(`FANUZU_HUD Proto.html`)에서 HUD 는 행성 위에 떠 있는 +/// 스프라이트(`hud.position.y = radius + 5`)다. 화면 최상단 고정 패널이 아니다. +/// 여기서는 행성의 장면 좌표를 현재 뷰 변환으로 화면 좌표에 옮겨 그 위에 카드를 +/// 세우고, 카드와 행성을 선으로 잇는다. +class _PlanetHud extends StatefulWidget { + const _PlanetHud({ + required this.planet, + required this.index, + required this.spin, + required this.outerRank, + required this.view, + required this.viewport, + required this.flying, + required this.card, + }); + + final PlanetNode planet; + final int index; + final double spin; + final int outerRank; + final Matrix4 view; + final Size viewport; + + /// 중심 전환이 진행 중인가. 진행 중에는 위/아래를 다시 정하지 않는다. + final bool flying; + final Widget card; + + @override + State<_PlanetHud> createState() => _PlanetHudState(); +} + +class _PlanetHudState extends State<_PlanetHud> { + /// 카드가 행성 위에 서는가. **비행 중에는 붙들어 둔다** — 화면 좌표가 + /// 매 프레임 크게 움직이므로 그때그때 다시 정하면 HUD 가 위아래로 튄다. + bool? _above; + + @override + void didUpdateWidget(_PlanetHud old) { + super.didUpdateWidget(old); + // 행성이 바뀌면 새로 정한다 — 다음 프레임(비행 첫 프레임)의 값으로 굳는다. + if (old.planet.id != widget.planet.id) _above = null; + } + + @override + Widget build(BuildContext context) { + final planet = widget.planet; + final index = widget.index; + final spin = widget.spin; + final viewport = widget.viewport; + final at = GalaxyField.positionOf(planet, index, spin, widget.outerRank); + final screen = MatrixUtils.transformPoint(widget.view, at); + final zoom = widget.view.getMaxScaleOnAxis(); + // 일러스트는 반지름의 3.2배 상자에 그려지므로 눈에 보이는 반지름은 1.6배다. + final r = + GalaxyField.screenRadiusOf(planet, index, spin, widget.outerRank) * + 1.6 * + zoom; + + // 행성이 화면 밖이면 HUD 도 없다. 카드만 가장자리에 clamp 해 두면 어느 + // 행성의 정보인지 알 수 없고, 연결선은 화면 밖 좌표에 그려진다. + if (screen.dx < -r || + screen.dx > viewport.width + r || + screen.dy < -r || + screen.dy > viewport.height + r) { + return const SizedBox.shrink(); + } + + const w = GalaxyMotion.hudWidth; + const gap = GalaxyMotion.hudGap; + const margin = GalaxyMotion.hudMargin; + if (_above == null || !widget.flying) { + _above = screen.dy - r - gap - GalaxyMotion.hudHeightHint > margin; + } + final above = _above!; + + final left = (screen.dx - w / 2).clamp( + margin, + (viewport.width - w - margin).clamp(margin, double.infinity), + ); + final stemTop = above ? screen.dy - r - gap : screen.dy + r; + + return Stack( + clipBehavior: Clip.none, + children: [ + // 행성과 카드를 잇는 선 — 「하나의 정보군」으로 읽히게 하는 장치다. + Positioned( + left: screen.dx - 1, + top: stemTop, + width: 2, + height: gap, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // 행성에 닿는 쪽이 진하다. + colors: above + ? const [Color(0x00000000), AppColors.cosmicAccent] + : const [AppColors.cosmicAccent, Color(0x00000000)], + ), + ), + ), + ), + Positioned( + left: left, + width: w, + top: above ? null : screen.dy + r + gap, + bottom: above ? viewport.height - (screen.dy - r - gap) : null, + child: widget.card, + ), + ], + ); + } +} + +class _HudCard extends StatelessWidget { + final PlanetWatch watchPlanet; + const _HudCard({ + required this.watchPlanet, + required this.planet, + required this.fpiMax, + required this.planetRepository, + required this.eclipse, + required this.eclipseRepository, + }); + + final PlanetNode planet; + final int fpiMax; + + /// 이 행성의 활성 이클립스. null 이면 침식 줄을 그리지 않는다. + final EclipseState? eclipse; + + /// 행성 상세로 그대로 넘기는 저장소. 상세의 침식 배너가 이것을 구독한다. + final EclipseRepository eclipseRepository; + + /// 눌러 들어갈 상세의 저장소를 **선택된 행성의 ID 로** 만든다. + final PlanetRepositoryFactory planetRepository; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return TweenAnimationBuilder( + // 선택이 바뀌면 키가 바뀌어 처음부터 다시 흐른다 — 「HUD 데이터 즉시 연동」이 + // 눈에 보이게 하는 신호다. AnimatedSwitcher 를 쓰면 전환 중 HUD 가 둘이 된다. + key: ValueKey('hud-${planet.id}'), + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + builder: (context, v, child) => Opacity( + opacity: v, + child: Transform.scale(scale: 0.92 + 0.08 * v, child: child), + ), + child: Material( + color: AppColors.cosmicSurfaceContainer.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + // 명세 — HUD 터치시 FPI 상세 페이지 이동. + onTap: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + repository: planetRepository(planet.id), + watchPlanet: watchPlanet, + eclipse: eclipseRepository, + ), + ), + ), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.cosmicAccent.withValues(alpha: 0.45), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s3, + vertical: AppSpace.s2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + planet.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.titleSmall, + ), + ), + // Stage0 성운은 랭킹 모집단이 아니다(B-17). + _Metric(label: 'RANK', value: planet.stage == 0 ? '-' : '${planet.rank}'), + ], + ), + const SizedBox(height: AppSpace.s1), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('FPI', style: t.labelSmall), + const SizedBox(width: AppSpace.s2), + Text( + formatPop(planet.fpi), + style: t.titleSmall?.copyWith( + color: AppColors.cosmicAccent, + ), + ), + ], + ), + const SizedBox(height: AppSpace.s1), + // FPI 게이지 — 프로토타입 HUD 우측의 막대와 같은 역할이다. + // 숫자만으로는 다른 행성과의 거리가 읽히지 않는다. + _FpiGauge(ratio: fpiMax == 0 ? 0 : planet.fpi / fpiMax), + const SizedBox(height: AppSpace.s1), + // 침식·방어 진행 — `fn-eclipse-aggregate` 의 `erosionRate` 와 + // 샤드 합이다. HUD 가 실시간으로 이 값을 따른다(스펙 B). + if (eclipse case final e?) ...[ + Row( + children: [ + Icon( + e.defended + ? Icons.verified_outlined + : Icons.dark_mode_outlined, + size: 12, + color: e.defended + ? AppColors.neonEmerald + : AppColors.neonMagenta, + ), + const SizedBox(width: AppSpace.s1), + Expanded( + child: Text( + e.defended + ? context.l10n.galaxyDefended + : context.l10n.galaxyErosion( + '${e.erosionRate}', + '${(e.progress * 100).round()}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.labelSmall?.copyWith( + color: e.defended + ? AppColors.neonEmerald + : AppColors.neonMagenta, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpace.s1), + ], + Row( + children: [ + Expanded( + child: Text( + context.l10n.galaxyOnlineMembers( + formatPop(planet.online), + formatPop(planet.members), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.labelSmall, + ), + ), + const Icon( + Icons.chevron_right, + size: 16, + color: AppColors.onCosmicVariant, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _FpiGauge extends StatelessWidget { + const _FpiGauge({required this.ratio}); + + final double ratio; + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + minHeight: 3, + backgroundColor: AppColors.cosmicOutline.withValues(alpha: 0.4), + valueColor: const AlwaysStoppedAnimation(AppColors.cosmicAccent), + ), + ); +} + +class _Metric extends StatelessWidget { + const _Metric({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(label, style: t.labelSmall), + const SizedBox(width: AppSpace.s1), + Text( + value, + style: t.titleSmall?.copyWith(color: AppColors.cosmicAccent), + ), + ], + ); + } +} + +/// 섬광 배너 한 자리 — 라이브 이벤트를 구독해 최신 한 건을 흘린다. +/// +/// 근거: 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프가 **타 사용자 +/// 화면에** 실시간 노출」. 내 방어가 아니라 **팬덤 전체의 방어**가 흘러야 하므로 +/// 원천이 내 호출 결과가 아니라 서버 컬렉션이다. +/// +/// 새 이벤트가 오면 문서 ID 가 바뀌어 슬라이드가 처음부터 다시 흐른다 — 명세의 +/// *「우측에서 좌측으로 1회 슬라이드」* 가 이벤트마다 한 번씩 일어난다. +class _LiveFlash extends StatelessWidget { + const _LiveFlash({ + required this.repository, + required this.fandomId, + required this.fallback, + }); + + final EclipseRepository repository; + final String? fandomId; + + /// 라이브 이벤트가 없을 때 그릴 기존 `galaxyEvents` 한 건. + final GalaxyEvent? fallback; + + @override + Widget build(BuildContext context) { + if (fandomId == null) { + return fallback == null + ? const SizedBox.shrink() + : _EventTicker( + key: const ValueKey('galaxy-event'), + message: fallback!.message, + alert: fallback!.kind == GalaxyEventKind.eclipse, + ); + } + return LiveStreamBuilder>( + key: ValueKey('flash-$fandomId'), + create: () => repository.liveEvents(fandomId: fandomId, limit: 5), + builder: (context, events) { + if (events == null || events.isEmpty) { + return fallback == null + ? const SizedBox.shrink() + : _EventTicker( + key: const ValueKey('galaxy-event'), + message: fallback!.message, + alert: fallback!.kind == GalaxyEventKind.eclipse, + ); + } + final latest = events.first; + return _EventTicker( + key: ValueKey('live-${latest.id}'), + message: liveEventMessage(latest), + alert: latest.kind == LiveEventKind.eclipseDefense, + ); + }, + ); + } +} + +/// 이벤트 메시지 — 명세 *디폴트 hide · 우측에서 좌측으로 1회 슬라이드*. +class _EventTicker extends StatefulWidget { + const _EventTicker({super.key, required this.message, required this.alert}); + + final String message; + + /// 이클립스 계열인가. 색을 가른다. + final bool alert; + + @override + State<_EventTicker> createState() => _EventTickerState(); +} + +class _EventTickerState extends State<_EventTicker> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..forward(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final color = widget.alert ? AppColors.error : AppColors.cosmicAccent; + return SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _c, curve: Curves.easeOut)), + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s3, + vertical: AppSpace.s2, + ), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceRaised, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: color.withValues(alpha: 0.6)), + ), + child: Text( + widget.message, + style: t.bodySmall?.copyWith(color: color), + ), + ), + ), + ); + } +} + +void _todo(BuildContext context, String where) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.commonNotYet(where)), + behavior: SnackBarBehavior.floating, + ), + ); +} diff --git a/app/lib/features/galaxy/galaxy_search_screen.dart b/app/lib/features/galaxy/galaxy_search_screen.dart new file mode 100644 index 0000000..e85fdd1 --- /dev/null +++ b/app/lib/features/galaxy/galaxy_search_screen.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/tokens.dart'; +import '../../core/ui/play_banner.dart'; +import 'galaxy_data.dart'; +import '../../core/format.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 갤럭시 검색 — Figma `03_Wireframe` 「Galaxy_검색」(`353:217832`). +/// +/// 화면 대조 원장 F2 의 11번. 명세 *돋보기 — 검색페이지 이동*. +/// +/// Figma 검색 화면이 공통으로 갖는 것을 그대로 따랐다 — +/// 「최근 검색어 / 전체 삭제 / 최근 검색어가 없습니다.」 +class GalaxySearchScreen extends StatefulWidget { + const GalaxySearchScreen({ + super.key, + this.repository = const SampleGalaxyRepository(), + }); + + final GalaxyRepository repository; + + @override + State createState() => _GalaxySearchScreenState(); +} + +class _GalaxySearchScreenState extends State { + late Future _snapshot; + final _controller = TextEditingController(); + + /// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다. + final List _recent = []; + + String _query = ''; + + @override + void initState() { + super.initState(); + _snapshot = widget.repository.load(); + _controller.addListener(() => setState(() => _query = _controller.text)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit(String q) { + final term = q.trim(); + if (term.isEmpty) return; + setState(() { + _recent + ..remove(term) + ..insert(0, term); + if (_recent.length > 10) _recent.removeLast(); + }); + } + + @override + Widget build(BuildContext context) => Scaffold( + // 시안 `Top Navigation`(54 · 좌우 16) — 뒤로 42 + 검색 칸 #282828 r10 높이 42. + appBar: AppBar( + toolbarHeight: 54, + automaticallyImplyLeading: false, + titleSpacing: 16, + centerTitle: false, + title: Row( + children: [ + const SizedBox.square(dimension: 42, child: BackButton()), + Expanded( + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFF282828), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Image.asset( + 'assets/figma/icon-search-20.png', + width: 20, + height: 20, + ), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.search, + onSubmitted: _submit, + style: _t(16, AppColors.onCosmic, FontWeight.w500, 1.4), + decoration: InputDecoration( + hintText: context.l10n.galaxySearchHint, + hintStyle: _t( + 16, + const Color(0xFF6E6E6E), + FontWeight.w500, + 1.4, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + if (_query.isNotEmpty) + GestureDetector( + onTap: _controller.clear, + child: const Icon( + Icons.close, + size: 18, + color: Color(0xFF6E6E6E), + ), + ), + ], + ), + ), + ), + ], + ), + ), + body: SafeArea( + child: FutureBuilder( + future: _snapshot, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final planets = snap.data?.planets ?? const []; + if (_query.trim().isEmpty) { + return _Recent( + terms: _recent, + onClear: () => setState(_recent.clear), + onPick: (t) { + _controller.text = t; + _submit(t); + }, + ); + } + final hits = planets + .where( + (p) => + p.name.toLowerCase().contains(_query.trim().toLowerCase()), + ) + .toList(); + if (hits.isEmpty) return const _NoHit(); + return ListView.separated( + padding: const EdgeInsets.all(AppSpace.s4), + itemCount: hits.length, + separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2), + itemBuilder: (context, i) => _Hit(planet: hits[i]), + ); + }, + ), + ), + ); +} + +TextStyle _t(double size, Color color, FontWeight weight, double height) => + TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: weight, + height: height, + color: color, + ); + +/// 최근 검색어 — 시안 `Frame 4241`(안쪽 16 · 간격 24): 배너, 「최근 검색어 16/600 · +/// 전체 삭제 14/500 #c2c2c2」, 비었으면 140 칸 가운데 「최근 검색어가 없습니다.」 14/600 #6e6e6e. +class _Recent extends StatelessWidget { + const _Recent({ + required this.terms, + required this.onClear, + required this.onPick, + }); + + final List terms; + final VoidCallback onClear; + final ValueChanged onPick; + + @override + Widget build(BuildContext context) => ListView( + // 시안 배너만 좌우 9(357 폭), 나머지는 16. + padding: const EdgeInsets.fromLTRB(9, 16, 9, 16), + children: [ + const PlayBanner(), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 7), + child: _body(context), + ), + ], + ); + + Widget _body(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.commonRecentSearches, + style: _t(16, AppColors.onCosmic, FontWeight.w600, 20 / 16), + ), + GestureDetector( + onTap: onClear, + child: Text( + context.l10n.commonClearAll, + style: _t(14, const Color(0xFFC2C2C2), FontWeight.w500, 20 / 14), + ), + ), + ], + ), + const SizedBox(height: AppSpace.s4), + if (terms.isEmpty) + SizedBox( + height: 140, + child: Center( + child: Text( + context.l10n.commonNoRecentSearches, + style: _t(14, const Color(0xFF6E6E6E), FontWeight.w600, 20 / 14), + ), + ), + ) + else + Wrap( + spacing: AppSpace.s2, + runSpacing: AppSpace.s2, + children: [ + for (final term in terms) + ActionChip(label: Text(term), onPressed: () => onPick(term)), + ], + ), + ], + ); +} + +class _NoHit extends StatelessWidget { + const _NoHit(); + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(AppSpace.s5), + child: Text( + context.l10n.galaxyNoHit, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ); +} + +class _Hit extends StatelessWidget { + const _Hit({required this.planet}); + + final PlanetNode planet; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Container( + padding: const EdgeInsets.all(AppSpace.s4), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + children: [ + Text( + '${planet.rank}', + style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent), + ), + const SizedBox(width: AppSpace.s4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(planet.name, style: t.bodyMedium), + Text( + context.l10n.galaxyMembers(formatPop(planet.members)), + style: t.labelSmall, + ), + ], + ), + ), + Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall), + ], + ), + ); + } +} diff --git a/app/lib/features/galaxy/widgets/galaxy_field.dart b/app/lib/features/galaxy/widgets/galaxy_field.dart new file mode 100644 index 0000000..dc26633 --- /dev/null +++ b/app/lib/features/galaxy/widgets/galaxy_field.dart @@ -0,0 +1,398 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/live_planet.dart'; +import '../../../core/data/planet_visual_state.dart'; +import '../galaxy_data.dart'; +import '../galaxy_motion.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// 갤럭시 맵의 행성 배치와 그리기. +/// +/// Figma 「갤럭시맵 화면」 명세 — +/// *규칙 : 3d갤럭시 브라우징 줌인 줌아웃 · 행성 나의행성 한가운데* +/// *동작 : 3d갤럭시 터치 이동 · 행성 터치시 화면 중심 전환이동* +/// +/// 중심 행성([PlanetNode.isCenter] — 기본은 내 행성, Ranking View 는 FPI 1위)이 원점에 +/// 서고 나머지가 그 둘레를 돈다. 반지름은 순위를 따르므로 **위치 자체가 순위 정보**다 — +/// 가까울수록 상위다. 내 행성은 어느 보기에서든 분홍 글로우 · 굵은 이름으로 드러난다. +/// +/// 깊이감은 네 가지로 낸다(검토회신서 3절 *「브라우징·줌 및 공간 깊이감」*). +/// 1. 궤도 타원을 눕힌다([GalaxyMotion.tilt]). +/// 2. 궤도 앞쪽(화면 아래)의 행성을 크게, 뒤쪽을 작게([GalaxyMotion.depthScale]). +/// 3. 뒤쪽을 옅게([GalaxyMotion.depthFade]) — 대기 원근. +/// 4. 앞쪽 행성이 뒤쪽을 가리도록 **그리는 순서를 깊이로 정한다**. +/// 별밭 시차는 화면 좌표라 여기가 아니라 [GalaxyStarfield] 가 가진다. +class GalaxyField extends StatelessWidget { + const GalaxyField({ + super.key, + this.watchPlanet = noPlanetWatch, + this.onOpenHistory, + required this.planets, + required this.selected, + required this.onSelect, + required this.spin, + required this.pulse, + }); + + final PlanetWatch watchPlanet; + final ValueChanged? onOpenHistory; + final List planets; + final PlanetNode? selected; + final ValueChanged onSelect; + + /// 궤도 회전 각도(rad). [GalaxyClock] 이 굴린다. + final double spin; + + /// 0~1 을 반복하는 값. 링 맥동에 쓴다. + final double pulse; + + /// 맵 논리 크기. [InteractiveViewer] 가 이 위를 훑는다. + static const double extent = 900; + + /// 가장 안쪽 궤도의 반지름. + static const double innerRing = 120; + + /// 궤도가 그대로 벌어질 때의 고리 간격 — 행성이 몇 개 없을 때 쓰는 값이다. + static const double ringGap = 52; + + /// 맵 가장자리에 남기는 여백. 행성 일러스트가 반지름의 1.6배까지 나가고 + /// 깊이 배율이 거기에 1.26배를 더 얹는다. + static const double edgeMargin = 60; + + /// 맵에서 가장 낮은 순위. 궤도 간격을 정하는 유일한 입력이다. + static int outerRankOf(List planets) => + planets.fold(1, (m, p) => p.rank > m ? p.rank : m); + + /// 궤도 반지름. + /// + /// **간격은 행성 수를 따라 좁아진다.** [ringGap] 을 그대로 쓰면 8위부터 궤도가 + /// 맵 반쪽(`extent/2`)을 넘어 [Stack] 에 잘려 나간다 — 30개 중 20개, 100개 중 + /// 90개가 사라진다. 바깥 궤도가 [edgeMargin] 안쪽에 서도록 간격을 나눈다. + static double ringOf(int rank, int outerRank) { + final rings = math.max(1, outerRank - 1); + final gap = math.min(ringGap, (extent / 2 - innerRing - edgeMargin) / rings); + return innerRing + (rank - 1) * gap; + } + + /// 행성의 논리 좌표. 그리기·히트 테스트·HUD 가 같은 식을 써야 어긋나지 않는다. + static Offset positionOf(PlanetNode p, int index, double spin, int outerRank) { + if (p.isCenter) return const Offset(extent / 2, extent / 2); + // 순위가 낮을수록 멀어진다. 중심 행성을 뺀 나머지를 고리에 흩는다. + final ring = ringOf(p.rank, outerRank); + final base = (index * 2 * math.pi) / 5.4; + // 바깥 궤도가 더 느리게 돈다. 같은 속도로 돌면 판때기 한 장이 도는 것처럼 + // 보여 깊이가 죽는다. + final angle = base + spin * GalaxyMotion.orbitSpeedOf(p.rank); + return Offset( + extent / 2 + ring * math.cos(angle), + extent / 2 + ring * math.sin(angle) * GalaxyMotion.tilt, + ); + } + + /// 순위가 낮을수록 작다. **바닥을 두는 이유**는 원식(`26 - rank*1.4`)이 + /// 19위부터 음수가 되기 때문이다. + /// ponytail: 바닥이 9 라 순위가 아주 낮은 구간은 크기가 같다. 100개처럼 + /// 빽빽한 맵에서 겹쳐 보이면 [ringOf] 의 간격에 크기를 묶는다. + static double radiusOf(PlanetNode p) => + p.isCenter ? 34 : (26 - p.rank * 1.4).clamp(9.0, 26.0); + + /// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이. + static double depthOf(Offset at) => + ((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp(-1.0, 1.0); + + /// 2.5D 깊이 배율. 궤도 앞쪽일수록 크다. + static double depthScaleOf(Offset at) => + 1 + depthOf(at) * GalaxyMotion.depthScale; + + /// 대기 원근 — 뒤쪽 행성이 옅어진다. + static double depthOpacityOf(Offset at) => + 1 - (1 - depthOf(at)) / 2 * GalaxyMotion.depthFade; + + /// 행성이 화면에서 차지하는 반지름. HUD 가 이 위에 붙는다. + static double screenRadiusOf( + PlanetNode p, + int index, + double spin, + int outerRank, + ) { + final at = positionOf(p, index, spin, outerRank); + 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'); + + @override + Widget build(BuildContext context) { + // 앞에 있는 행성이 뒤를 가려야 입체로 읽힌다. 인덱스는 궤도 각도를 정하는 + // 값이라 바꿀 수 없으므로, 인덱스를 들고 다니면서 깊이로만 다시 세운다. + final outerRank = outerRankOf(planets); + final ordered = [ + for (final (i, p) in planets.indexed) + (i, p, positionOf(p, i, spin, outerRank)), + ]..sort((a, b) => a.$3.dy.compareTo(b.$3.dy)); + + return SizedBox( + width: extent, + height: extent, + child: Stack( + children: [ + // 궤도선은 순위가 바뀔 때만 달라진다. 같은 층에 두면 궤도가 도는 매 + // 프레임 다시 래스터화되므로 RepaintBoundary 로 층을 갈라 한 번만 굽는다. + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint( + painter: _BackdropPainter(planets: planets, outerRank: outerRank), + isComplex: true, + willChange: false, + ), + ), + ), + // 터치 대상은 그림이 아니라 실제 위젯이다. CustomPaint 안에서 히트 + // 테스트를 손으로 계산하면 줌 배율이 바뀔 때마다 어긋난다. + for (final (i, p, at) in ordered) + _PlanetHit( + // 유일한 것은 이름이 아니라 문서 ID 다 — 동명 팬덤이 둘이면 + // 이름으로는 둘 다 선택 상태로 그려진다. 깊이 순으로 자리를 + // 바꿔도 위젯이 살아남게 하는 키이기도 하다. + key: ValueKey(p.id), + planet: p, + watchPlanet: watchPlanet, + image: imageOf(p, i), + center: at, + radius: radiusOf(p) * depthScaleOf(at), + opacity: depthOpacityOf(at), + selected: selected?.id == p.id, + onTap: () => onSelect(p), + onOpenHistory: onOpenHistory == null ? null : () => onOpenHistory!(p), + ), + ], + ), + ); + } +} + +class _PlanetHit extends StatelessWidget { + final PlanetWatch watchPlanet; + final VoidCallback? onOpenHistory; + const _PlanetHit({ + required this.watchPlanet, + this.onOpenHistory, + super.key, + required this.planet, + required this.image, + required this.center, + required this.radius, + required this.opacity, + required this.selected, + required this.onTap, + }); + + final PlanetNode planet; + final String image; + final Offset center; + final double radius; + final double opacity; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // 일러스트는 구름·장식이 본체 밖까지 나가 있어 반지름의 3배로 그린다. + // 터치 타깃도 그 크기다. + final side = radius * 3.2; + final glow = planet.isMine ? AppColors.cosmicAccent : AppColors.neonViolet; + // 선택한 행성은 깊이와 무관하게 또렷하다 — HUD 와 한 정보군으로 읽혀야 한다. + final fade = selected ? 1.0 : opacity; + return Positioned( + left: center.dx - side / 2, + top: center.dy - side / 2, + width: side, + height: side, + child: Semantics( + button: true, + label: context.l10n.galaxyPlanetRank(planet.name, '${planet.rank}'), + selected: selected, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Opacity( + opacity: fade, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // 2. 글로우 아우라 — 선택하면 진해진다. + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + glow.withValues(alpha: selected ? 0.55 : 0.32), + glow.withValues(alpha: 0), + ], + stops: const [0.3, 0.75], + ), + ), + ), + ), + // 3~5. 본체 일러스트(표면 장식·구름 포함). + LivePlanet25D(onOpenHistory: onOpenHistory, fandomId: planet.id, watch: watchPlanet, image: image, size: side, stage: planet.stage, glow: glow, + animate: selected, lowEffects: !selected), + Positioned( + bottom: -4, + child: Text( + planet.name, + style: TextStyle( + fontSize: planet.isMine ? 13 : 11, + color: selected + ? AppColors.cosmicAccent + : AppColors.onCosmic, + fontWeight: planet.isMine + ? FontWeight.w600 + : FontWeight.w400, + shadows: const [Shadow(color: Colors.black, blurRadius: 6)], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 움직이지 않는 배경 — 궤도선. +/// +/// 궤도 반지름은 순위를 따르므로 순위가 바뀔 때만 다시 그리면 된다. +/// 별밭은 여기 없다. 시차를 주려면 화면 좌표에 있어야 해서 [GalaxyStarfield] 로 뺐다. +class _BackdropPainter extends CustomPainter { + const _BackdropPainter({required this.planets, required this.outerRank}); + + final List planets; + final int outerRank; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final orbit = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = AppColors.cosmicOutline.withValues(alpha: 0.5); + for (final p in planets.where((p) => !p.isCenter)) { + final ring = GalaxyField.ringOf(p.rank, outerRank); + canvas.drawOval( + Rect.fromCenter( + center: center, + width: ring * 2, + height: ring * 2 * GalaxyMotion.tilt, + ), + orbit, + ); + } + } + + /// 순위가 그대로면 다시 그리지 않는다 — 궤도 배치를 정하는 유일한 입력이다. + @override + bool shouldRepaint(_BackdropPainter old) => + old.planets.length != planets.length || + old.outerRank != outerRank || + !_sameRanks(old.planets, planets); + + static bool _sameRanks(List a, List b) { + for (var i = 0; i < a.length; i++) { + if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter) return false; + } + return true; + } +} + +/// 별밭 — 맵보다 **덜** 움직여서 멀리 있어 보이게 하는 층(시차). +/// +/// 맵과 같이 움직이면 별이 맵에 붙은 무늬가 된다. 화면 좌표에 두고 +/// [GalaxyMotion.parallax] 만큼만 따라오게 해야 「그 뒤의 공간」이 된다. +/// 별 자체는 [RepaintBoundary] 안에서 한 번만 굽고 이동·확대는 합성으로 처리한다 +/// — 매 프레임 별 220개를 다시 그리면 팬·줌이 끊긴다. +class GalaxyStarfield extends StatelessWidget { + const GalaxyStarfield({super.key, required this.view}); + + /// 맵의 현재 변환. 이동량과 배율을 여기서 읽는다. + final Matrix4 view; + + @override + Widget build(BuildContext context) { + final t = view.getTranslation(); + final scale = view.getMaxScaleOnAxis(); + const limit = GalaxyMotion.parallaxLimit; + final dx = (t.x * GalaxyMotion.parallax).clamp(-limit, limit); + final dy = (t.y * GalaxyMotion.parallax).clamp(-limit, limit); + return ClipRect( + child: Transform.translate( + offset: Offset(dx, dy), + child: Transform.scale( + scale: 1 + (scale - 1) * GalaxyMotion.parallaxZoom, + child: OverflowBox( + minWidth: 0, + minHeight: 0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: SizedBox( + // 시차로 밀리는 만큼 화면보다 크게 그려 둔다. + width: MediaQuery.sizeOf(context).width + limit * 2, + height: MediaQuery.sizeOf(context).height + limit * 2, + child: const RepaintBoundary( + child: CustomPaint(painter: _StarfieldPainter()), + ), + ), + ), + ), + ), + ); + } +} + +class _StarfieldPainter extends CustomPainter { + const _StarfieldPainter(); + + @override + void paint(Canvas canvas, Size size) { + // 좌표는 고정 시드로 뽑는다. 매번 새로 뽑으면 프레임마다 별이 튄다. + final rnd = math.Random(20260831); + for (var i = 0; i < 220; i++) { + // 먼 별은 작고 옅다 — 별밭 안에서도 층이 진다. + final depth = rnd.nextDouble(); + canvas.drawCircle( + Offset(rnd.nextDouble() * size.width, rnd.nextDouble() * size.height), + 0.4 + depth * 1.1, + Paint() + ..color = AppColors.onCosmicVariant.withValues( + alpha: 0.14 + depth * 0.24, + ), + ); + } + } + + @override + bool shouldRepaint(_StarfieldPainter old) => false; +} + +/// 배경 층의 그리기 비용. 이 층은 프레임마다 도는 것이 아니라 한 번만 굽는다. +@visibleForTesting +CustomPainter galaxyBackdropPainterForTest({ + required List planets, +}) => _BackdropPainter( + planets: planets, + outerRank: GalaxyField.outerRankOf(planets), +); + +/// 별밭 층의 그리기 비용. +@visibleForTesting +CustomPainter galaxyStarfieldPainterForTest() => const _StarfieldPainter(); diff --git a/app/lib/features/galaxy/widgets/galaxy_mini_map.dart b/app/lib/features/galaxy/widgets/galaxy_mini_map.dart new file mode 100644 index 0000000..632e8b7 --- /dev/null +++ b/app/lib/features/galaxy/widgets/galaxy_mini_map.dart @@ -0,0 +1,802 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/planet_25d.dart'; +import '../../../core/format.dart'; +import '../galaxy_data.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// HOME GALAXY 미니맵 — 발주사 프로토타입 v6.2(`UZU HUD (2).html`) +/// 의 3D 장면을 [CustomPainter] 로 옮긴 것(정책 원장 C-49). +/// +/// - 카메라·궤도·행성 크기·HUD 배치는 프로토타입 수식 그대로다. 게이지 값은 프로토타입 +/// 기본값(Mass x1.5 · Orbit x1.0 · HUD x1.0 · Camera 2000 · Rotation 0°)으로 고정한다. +/// - **게이지 조절 8개는 넣지 않는다(C-51).** +/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49). +/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다. +/// +/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입 +/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen]. +class GalaxyMiniMap extends StatefulWidget { + const GalaxyMiniMap({ + super.key, + required this.data, + required this.onOpen, + this.size = 140, + }); + + final GalaxySnapshot data; + final VoidCallback onOpen; + + /// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시). + final double size; + + @override + State createState() => _GalaxyMiniMapState(); +} + +class _GalaxyMiniMapState extends State + with SingleTickerProviderStateMixin { + final _painter = GalaxyMiniMapPainter(); + late final Ticker _ticker = createTicker(_tick); + Duration _last = Duration.zero; + + @override + void initState() { + super.initState(); + _painter.update(widget.data.planets); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final motion = + Planet25D.animationsEnabled && !MediaQuery.disableAnimationsOf(context); + if (motion && !_ticker.isActive) { + _last = Duration.zero; + _ticker.start(); + } else if (!motion) { + _ticker.stop(); + _painter.settle(); + } + } + + @override + void didUpdateWidget(GalaxyMiniMap old) { + super.didUpdateWidget(old); + if (identical(old.data, widget.data)) return; + _painter.update(widget.data.planets); + if (!_ticker.isActive) _painter.settle(); + } + + void _tick(Duration now) { + final dt = _last == Duration.zero + ? 0.0 + : (now - _last).inMicroseconds / Duration.microsecondsPerSecond; + _last = now; + // 백그라운드 복귀처럼 프레임을 오래 건너뛴 뒤 한 번에 튀지 않게 한다. + _painter.advance(dt.clamp(0.0, 1 / 20)); + } + + @override + void dispose() { + _ticker.dispose(); + _painter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final mine = widget.data.mine; + return Semantics( + button: true, + label: [ + if (mine != null) ...GalaxyMiniMapPainter.hudLines(mine) else 'GALAXY', + context.l10n.galaxyMapOpen, + ].join(' · '), + onTap: widget.onOpen, + child: GestureDetector( + excludeFromSemantics: true, + onHorizontalDragUpdate: (d) { + _painter.turn(d.delta.dx / widget.size * math.pi); + if (!_ticker.isActive) _painter.settle(); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.md), + child: ExcludeSemantics( + child: Stack( + children: [ + // 매 프레임 다시 그리므로 HOME 히어로와 레이어를 가른다. + RepaintBoundary( + child: CustomPaint( + size: Size.square(widget.size), + painter: _painter, + isComplex: true, + willChange: true, + ), + ), + // 잉크는 불투명 장면 위에 얹어야 보인다. + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + excludeFromSemantics: true, + onTap: widget.onOpen, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 프로토타입 색(C-49 — 테마 결정 전까지 유지). +const _palette = [ + Color(0xFF00F2FF), + Color(0xFF8B5CF6), + Color(0xFF10B981), + Color(0xFFF43F5E), + Color(0xFFF59E0B), +]; +const _emerald = Color(0xFF10B981); + +/// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다. +/// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다. +Color _planetColor(PlanetNode p, int fpiIndex) => + planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length]; + +/// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51). +const _massVolume = 1.5; +const _orbitSpacing = 450 * _massVolume * 1.0; +const _camY = 2000.0; +const _camZ = 3500.0; +final _camLen = math.sqrt(_camY * _camY + _camZ * _camZ); + +/// 카메라가 원점을 본다 — 전방 (0, fy, fz) · 위 (0, uy, uz) · 오른쪽 (1, 0, 0). +final _fy = -_camY / _camLen; +final _fz = -_camZ / _camLen; +final _uy = _camZ / _camLen; +final _uz = -_camY / _camLen; +final _tanHalfFov = math.tan(35 * math.pi / 180); + +/// 프로토타입 HUD 텍스처 크기. +const _hudW = 512.0; +const _hudH = 256.0; + +/// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로. +double _px(double w) => w < 1 ? 0 : w; + +/// 60fps 기준 한 프레임 lerp 계수 [k] 를 경과 시간에 맞춘다. +double _lerpK(double k, double dt) => 1 - math.pow(1 - k, dt * 60).toDouble(); + +class _Body { + _Body(this.id, this.phase); + + final String id; + + /// 프로토타입은 궤도 위상에 행성 id(1~5)를 더한다. 여기서는 처음 들어온 순번. + final double phase; + + late PlanetNode node; + Object? _key; + double share = 0; + double rad = 0; + double target = 0; + double wx = 0; + double wz = 0; + double depth = 0; + double hudW = 0; + ui.Picture? hud; + ui.Picture? sphere; + Float32List sat = Float32List(0); + Float32List satOut = Float32List(0); + + /// 프로토타입 `15 + fpi / 200` — fpi 는 슬라이더 최대 5000 척도로 옮긴 값. + double get baseR => 15 + share * 25; + int get ringCount => node.rank == 1 ? 3 : (node.rank <= 3 ? 2 : 1); + + void set(PlanetNode p, Color color, double s) { + node = p; + target = (p.rank - 1) * _orbitSpacing; + final key = (p.name, p.rank, p.fpi, p.online, color, s); + if (key == _key) return; + _key = key; + share = s; + dispose(); + hud = _hudPicture(p, color, s); + sphere = _spherePicture(color); + // 프로토타입 위성 수 `fpi / 10`. + final n = (s * 5000 / 10).floor(); + final rnd = math.Random(p.id.hashCode); + sat = Float32List(n * 3); + satOut = Float32List(n * 2); + for (var j = 0; j < n; j++) { + final r = 1.8 + rnd.nextDouble() * 2.5; + final theta = rnd.nextDouble() * math.pi * 2; + final phi = rnd.nextDouble() * math.pi; + sat[j * 3] = r * math.sin(phi) * math.cos(theta); + sat[j * 3 + 1] = r * math.sin(phi) * math.sin(theta); + sat[j * 3 + 2] = r * math.cos(phi); + } + this.color = color; + } + + Color color = _emerald; + + void dispose() { + hud?.dispose(); + sphere?.dispose(); + hud = null; + sphere = null; + } +} + +/// 프로토타입 `generateHUDTexture` — 512×256 텍스처를 한 번만 그려 두고 매 프레임 변환해 찍는다. +ui.Picture _hudPicture(PlanetNode p, Color color, double share) { + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + const box = Rect.fromLTWH(10, 10, 492, 236); + canvas.drawRect( + box, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..color = color.withValues(alpha: 0.8), + ); + canvas.drawRect(box, Paint()..color = const Color(0xE6000514)); + canvas.save(); + canvas.clipRect(box); + final lines = GalaxyMiniMapPainter.hudLines(p); + for (final (text, baseline, size, weight, c) in [ + (lines[0], 80.0, 50.0, FontWeight.w800, color), + (lines[1], 120.0, 24.0, FontWeight.w700, const Color(0xB3FFFFFF)), + (lines[2], 180.0, 40.0, FontWeight.w800, _emerald), + (lines[3], 220.0, 24.0, FontWeight.w700, const Color(0xE6FFFFFF)), + ]) { + final tp = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontFamily: 'monospace', + fontSize: size, + fontWeight: weight, + color: c, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(); + tp.paint( + canvas, + Offset( + 40, + baseline - tp.computeDistanceToActualBaseline(TextBaseline.alphabetic), + ), + ); + tp.dispose(); + } + canvas.restore(); + // 프로토타입 막대 `160 * fpi / 5000` — 1위 대비 비율로 옮겼다. + canvas.drawRect( + Rect.fromLTWH(450, 40, 20, 160 * share), + Paint()..color = _emerald, + ); + return rec.endRecording(); +} + +/// 반지름 1 단위의 광륜(6R, 가산 합성) + 발광 구체. +ui.Picture _spherePicture(Color c) { + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + canvas.drawCircle( + Offset.zero, + 3, + Paint() + ..blendMode = BlendMode.plus + ..shader = ui.Gradient.radial( + Offset.zero, + 3, + [ + c.withValues(alpha: 0.4), + c.withValues(alpha: 0.15), + c.withValues(alpha: 0), + ], + const [0, 0.2, 0.6], + ), + ); + canvas.drawCircle( + Offset.zero, + 1, + Paint() + ..shader = ui.Gradient.radial( + const Offset(-0.35, -0.35), + 1.35, + [ + Color.lerp(c, const Color(0xFFFFFFFF), 0.6)!, + c, + Color.lerp(c, const Color(0xFF000000), 0.3)!, + ], + const [0, 0.45, 1], + ), + ); + return rec.endRecording(); +} + +/// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다. +class GalaxyMiniMapPainter extends CustomPainter { + GalaxyMiniMapPainter() : this._(ValueNotifier(0)); + GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame); + + final ValueNotifier _frame; + + /// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구. + static List hudLines(PlanetNode p) => [ + p.name.toUpperCase(), + 'RANK #${p.rank}', + 'FPI: ${p.fpi}', + 'LIVE: ${formatPop(p.online)}', + ]; + + final _bodies = {}; + final _order = <_Body>[]; + _Body? _mine; + + double _time = 0; + double _scale = 1; + double _rot = 0; + double _rotTarget = 0; + double _gx = 0; + double _gz = 0; + + /// 그리는 행성 수(내 행성은 순위 밖이어도 더한다). + static const maxPlanets = 10; + + /// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). + static const hudFloor = 0.7; + + /// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다. + static const fullHudMin = 280.0; + + /// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄. + static const compactNamePt = 11.0; + static const compactLinePt = 9.0; + + /// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`. + static String compactLine(PlanetNode p) => + '#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}'; + + ui.Picture? _compact; + Object? _compactKey; + Size _compactSize = Size.zero; + + /// 마지막으로 만든 간략 카드의 줄과 글자 크기. 없으면 비어 있다. + @visibleForTesting + List<(String, double)> compactText = const []; + + /// 내 행성 간략 카드. 데이터·최대 폭이 바뀔 때만 다시 만든다. + ui.Picture _compactCard(_Body b, double maxW) { + final key = (b._key, maxW); + if (key == _compactKey && _compact != null) return _compact!; + _compact?.dispose(); + _compactKey = key; + const pad = 6.0, barW = 3.0, gap = 4.0; + final textMax = maxW - pad * 2 - barW - gap; + TextPainter line(String text, double pt, FontWeight weight, Color c) => + TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: pt, + fontWeight: weight, + height: 1.2, + color: c, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 2, + ellipsis: '…', + )..layout(maxWidth: textMax); + final name = line( + b.node.name.toUpperCase(), + compactNamePt, + FontWeight.w800, + b.color, + ); + // 좁으면 LIVE 앞에서 둘째 줄이 한 번 꺾인다(글자 크기는 줄이지 않는다). + final stats = line( + compactLine(b.node), + compactLinePt, + FontWeight.w700, + const Color(0xE6FFFFFF), + ); + final textW = math.max(name.width, stats.width); + final w = pad * 2 + textW + gap + barW; + final h = pad * 2 + name.height + 2 + stats.height; + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + final box = Rect.fromLTWH(0, 0, w, h); + canvas.drawRect(box, Paint()..color = const Color(0xE6000514)); + canvas.drawRect( + box, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = b.color.withValues(alpha: 0.8), + ); + name.paint(canvas, const Offset(pad, pad)); + stats.paint(canvas, Offset(pad, pad + name.height + 2)); + final barH = h - pad * 2; + canvas.drawRect( + Rect.fromLTWH( + w - pad - barW, + pad + barH * (1 - b.share), + barW, + barH * b.share, + ), + Paint()..color = _emerald, + ); + compactText = [ + (b.node.name.toUpperCase(), compactNamePt), + (compactLine(b.node), compactLinePt), + ]; + name.dispose(); + stats.dispose(); + _compactSize = Size(w, h); + return _compact = rec.endRecording(); + } + + @visibleForTesting + double get rotation => _rot; + + /// 마지막 프레임에 그린 HUD 카드 폭(논리 px). 안 그렸으면 0. + @visibleForTesting + double? hudWidthOf(String id) => _bodies[id]?.hudW; + + /// 행성에 칠한 색. 없으면 null. + @visibleForTesting + Color? colorOf(String id) => _bodies[id]?.color; + + /// 행성의 현재 궤도 반지름(월드 단위). 없으면 null. + @visibleForTesting + double? orbitOf(String id) => _bodies[id]?.rad; + + void update(List planets) { + // FPI 상위 [maxPlanets] 개 + 내 행성. + final byFpi = [...planets]..sort((a, b) => b.fpi.compareTo(a.fpi)); + final shown = byFpi.take(maxPlanets).toList(); + final mine = planets.where((p) => p.isMine).firstOrNull; + if (mine != null && !shown.contains(mine)) shown.add(mine); + + final top = shown.fold(0, (m, p) => math.max(m, p.fpi)); + for (final (i, p) in shown.indexed) { + final share = top <= 0 ? 0.0 : (p.fpi / top).clamp(0.0, 1.0); + (_bodies[p.id] ??= _Body( + p.id, + _bodies.length + 1.0, + )).set(p, _planetColor(p, i), share); + } + final ids = {for (final p in shown) p.id}; + _bodies.removeWhere((id, b) { + if (ids.contains(id)) return false; + b.dispose(); + return true; + }); + _order + ..clear() + ..addAll(_bodies.values); + _mine = _order.where((b) => b.node.isMine).firstOrNull; + _place(0); + _frame.value++; + } + + void turn(double radians) => + _rotTarget = (_rotTarget + radians).clamp(-math.pi, math.pi); + + /// 움직임 없이 목표 상태로 바로 놓는다(애니메이션 끔 · 테스트). + void settle() { + _scale = _massVolume; + _rot = _rotTarget; + for (final b in _order) { + b.rad = b.target; + } + _place(1); + _frame.value++; + } + + /// 프로토타입 `animate()` 한 프레임 — lerp 계수는 60fps 기준 값을 [dt] 에 맞춘다. + void advance(double dt) { + _time += dt; + _scale += (_massVolume - _scale) * _lerpK(0.05, dt); + final k = _lerpK(0.03, dt); + for (final b in _order) { + b.rad += (b.target - b.rad) * k; + } + _rot += (_rotTarget - _rot) * _lerpK(0.05, dt); + _place(_lerpK(0.08, dt)); + _frame.value++; + } + + /// 궤도 위치 → 월드 좌표. 장면은 내 행성을 가운데로 따라간다(계수 [follow]). + void _place(double follow) { + final c = math.cos(_rot); + final s = math.sin(_rot); + if (_mine case final m?) { + final a = _angle(m); + final lx = math.cos(a) * m.rad; + final lz = math.sin(a) * m.rad; + // 프로토타입은 회전 전 좌표로 따라간다. 회전 중에도 내 행성이 가운데 있도록 회전 뒤 좌표를 쓴다. + _gx += (-(lx * c + lz * s) - _gx) * follow; + _gz += (-(-lx * s + lz * c) - _gz) * follow; + } + for (final b in _order) { + final a = _angle(b); + final lx = math.cos(a) * b.rad; + final lz = math.sin(a) * b.rad; + b.wx = lx * c + lz * s + _gx; + b.wz = -lx * s + lz * c + _gz; + } + } + + /// 프로토타입 `time * speed + id` — time = ms × 0.0005, speed = 0.8 / (rank + 1). + double _angle(_Body b) => _time * 0.5 * 0.8 / (b.node.rank + 1) + b.phase; + + // ── 투영 결과(할당 없이 필드로 돌려준다) ── + double _sx = 0; + double _sy = 0; + double _sd = 0; + + void _project(double x, double y, double z, double focal, Size size) { + final vy = y - _camY; + final vz = z - _camZ; + _sd = vy * _fy + vz * _fz; + _sx = size.width / 2 + x / _sd * focal; + _sy = size.height / 2 - (vy * _uy + vz * _uz) / _sd * focal; + } + + /// 수평 원이 카메라에서 납작해 보이는 비율. + double _fore(double x, double z) { + final dz = z - _camZ; + return _camY / math.sqrt(x * x + _camY * _camY + dz * dz); + } + + Size _bgSize = Size.zero; + ui.Picture? _bg; + Paint? _scan; + final _stroke = Paint()..style = PaintingStyle.stroke; + final _fill = Paint(); + + /// 별밭·성운 — 장면 밖(갤럭시 그룹 밖)이라 카메라가 고정이면 크기마다 한 번만 그린다. + /// ponytail: 프로토타입의 별 회전(0.0001rad/프레임)은 생략 — 분당 0.4° 라 미니맵에서 보이지 않는다. + void _buildBackground(Size size, double focal) { + _bgSize = size; + _bg?.dispose(); + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + canvas.drawRect( + Offset.zero & size, + Paint()..color = const Color(0xFF000000), + ); + final rnd = math.Random(62); + for (var i = 0; i < 8; i++) { + final x = (rnd.nextDouble() - 0.5) * 2000; + final y = (rnd.nextDouble() - 0.5) * 500; + final z = (rnd.nextDouble() - 0.5) * 2000; + _project(x, y, z, focal, size); + if (_sd < 1) continue; + final r = 1500 * focal / _sd; + final center = Offset(_sx, _sy); + canvas.save(); + canvas.translate(_sx, _sy); + canvas.scale(1, _fore(x, z)); + canvas.translate(-_sx, -_sy); + canvas.drawCircle( + center, + r, + Paint() + ..blendMode = BlendMode.plus + ..shader = ui.Gradient.radial( + center, + r, + const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)], + const [0, 0.4, 0.8], + ), + ); + canvas.restore(); + } + // 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다. + final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0); + final pts = []; + for (var i = 0; i < 8000; i++) { + final x = (rnd.nextDouble() - 0.5) * 15000; + final y = (rnd.nextDouble() - 0.5) * 15000; + final z = (rnd.nextDouble() - 0.5) * 15000; + final pick = rnd.nextDouble(); + _project(x, y, z, focal, size); + if (_sd < 1 || pick > keep) continue; + if (_sx < 0 || _sy < 0 || _sx > size.width || _sy > size.height) continue; + pts + ..add(_sx) + ..add(_sy); + } + canvas.drawRawPoints( + ui.PointMode.points, + Float32List.fromList(pts), + Paint() + ..strokeWidth = 0 + ..color = const Color(0xCCBFBFFF), + ); + _bg = rec.endRecording(); + _scan = Paint() + ..shader = ui.Gradient.linear( + Offset.zero, + const Offset(0, 4), + const [Color(0x0010B981), Color(0x1410B981), Color(0x0010B981)], + const [0, 0.5, 1], + ); + } + + static int _farFirst(_Body a, _Body b) => b.depth.compareTo(a.depth); + + @override + void paint(Canvas canvas, Size size) { + final focal = size.height / 2 / _tanHalfFov; + if (size != _bgSize) _buildBackground(size, focal); + canvas.drawPicture(_bg!); + + // 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/100, 흰색 15%. + _project(_gx, 0, _gz, focal, size); + final gx = _sx, gy = _sy, gd = _sd; + final gFore = _fore(_gx, _gz); + _stroke.color = const Color(0x26FFFFFF); + for (final b in _order) { + // ponytail: 카메라 평면에 걸치는 큰 궤도는 건너뛴다 — 원근 타원 근사가 깨지는 구간. + if (b.rad < 1 || gd - b.rad < 100) continue; + final rx = b.rad * focal / gd; + _stroke.strokeWidth = _px(b.rad / 100 * focal / gd); + canvas.drawOval( + Rect.fromCenter( + center: Offset(gx, gy), + width: rx * 2, + height: rx * 2 * gFore, + ), + _stroke, + ); + } + + for (final b in _order) { + _project(b.wx, 0, b.wz, focal, size); + b.depth = _sd; + } + _order.sort(_farFirst); + + final satRot = -1.8 * _time; // 프로토타입 -0.03rad/프레임 + final sc = math.cos(satRot), ss = math.sin(satRot); + for (final b in _order) { + final r = b.baseR * _scale; + // 근평면 — 카메라에 반지름 2배 이내로 붙은 행성은 광륜·띠 투영이 뒤집히므로 뺀다. + if (b.depth < r * 2) continue; + _project(b.wx, 0, b.wz, focal, size); + final px = _sx, py = _sy, d = _sd; + + // 위성 — 행성 반지름 1.8~4.3 배 구각. + final sat = b.sat, out = b.satOut; + for (var j = 0, n = sat.length ~/ 3; j < n; j++) { + final x = sat[j * 3] * r, + y = sat[j * 3 + 1] * r, + z = sat[j * 3 + 2] * r; + _project( + b.wx + x * sc + z * ss, + y, + b.wz - x * ss + z * sc, + focal, + size, + ); + // 카메라 뒤 점은 화면 밖으로 보낸다(버퍼 길이는 그대로). + final behind = _sd < 1; + out[j * 2] = behind ? -1e5 : _sx; + out[j * 2 + 1] = behind ? -1e5 : _sy; + } + if (out.isNotEmpty) { + _fill + ..strokeWidth = 0 + ..blendMode = BlendMode.plus + ..color = b.color.withValues( + alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0), + ); + canvas.drawRawPoints(ui.PointMode.points, out, _fill); + _fill.blendMode = BlendMode.srcOver; + } + + // 광륜 + 구체. + final rPx = r * focal / d; + canvas.save(); + canvas.translate(px, py); + canvas.scale(rPx); + canvas.drawPicture(b.sphere!); + canvas.restore(); + + // 행성 띠 — 1위 3줄, 2~3위 2줄, 나머지 1줄. + final fore = _fore(b.wx, b.wz); + for (var i = 0; i < b.ringCount; i++) { + final inner = b.baseR * (1.6 + i * 0.15) * _scale; + final thick = b.baseR * 0.03 * _scale; + final rx = (inner + thick / 2) * focal / d; + _stroke + ..strokeWidth = _px(thick * focal / d) + ..color = b.color.withValues(alpha: 0.3 / (i + 1)); + canvas.drawOval( + Rect.fromCenter( + center: Offset(px, py), + width: rx * 2, + height: rx * 2 * fore, + ), + _stroke, + ); + } + } + + // HUD — 깊이 검사 없이 맨 위(프로토타입 depthTest: false). + final f = math.pow(_scale, 0.22).toDouble(); + final hudHeight = 57.5 * f; + // [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만 + // 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만). + final full = size.width >= fullHudMin; + for (final b in _order) { + b.hudW = 0; + if (!full && !identical(b, _mine)) continue; + if (b.depth < b.baseR * _scale * 2) continue; + final y = _scale * (b.baseR * _scale + hudHeight / 2 + 2); + _project(b.wx, y, b.wz, focal, size); + if (_sd < 1) continue; + final w = 115 * f * _scale * focal / _sd; + if (full) { + b.hudW = w; + canvas.save(); + canvas.translate(_sx - w / 2, _sy - w / 4); + canvas.scale(w / _hudW, w / 2 / _hudH); + canvas.drawPicture(b.hud!); + canvas.restore(); + continue; + } + final card = _compactCard(b, size.width * hudFloor); + b.hudW = _compactSize.width; + canvas.save(); + // 아래 변은 프로토타입 카드 자리(행성 바로 위)에 두고 위로 키운다 — 행성을 가리지 않게. + canvas.translate( + _sx - _compactSize.width / 2, + _sy + w / 4 - _compactSize.height, + ); + canvas.drawPicture(card); + canvas.restore(); + } + + // 스캔라인 — 6초 주기로 위에서 아래로. + final scanY = -4 + (_time % 6) / 6 * (size.height + 4); + canvas.save(); + canvas.translate(0, scanY); + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, 4), _scan!); + canvas.restore(); + } + + @override + bool shouldRepaint(GalaxyMiniMapPainter old) => !identical(old, this); + + void dispose() { + for (final b in _order) { + b.dispose(); + } + _bg?.dispose(); + _compact?.dispose(); + _frame.dispose(); + } +} diff --git a/app/lib/features/galaxy/widgets/zap_button.dart b/app/lib/features/galaxy/widgets/zap_button.dart new file mode 100644 index 0000000..041274f --- /dev/null +++ b/app/lib/features/galaxy/widgets/zap_button.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/app_icons.dart'; +import '../../../core/i18n/app_locale.dart'; + +const _bolt = AssetImage('assets/figma/icon-bolt.png'); +const _item = AssetImage('assets/figma/icon-item.png'); +const _zapGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF4BC2AE), Color(0xFF4F98C8), Color(0xFF5669DC)], +); + +/// Zap — Figma 「Zap 버튼」 명세 그대로. +/// +/// *요소 : Zap 기본 · Zap 옵션 · 기여 · 아이템 · 닫기* +/// +/// **「자동(오토 도네이션)」은 두지 않는다.** 별첨 1 ① 시나리오 4 가 적었으나 별첨 B +/// 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항). +/// *규칙 : 화면 하단 위치 고정 · 디폴트 Zap 기본 노출 ·* +/// ***행성/성운 가입 또는 생성한 유저에게만 노출*** +/// *동작 : 기본 클릭 시 어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가 ·* +/// ***2초 롱프레스 시 옵션으로 전환*** +/// +/// 차감 수치가 **어드민 설정값**이라는 것이 명세에 박혀 있다. 화면에 상수로 박지 +/// 않고 [zapPop] 으로 받는다 — 정책 설계서 §P-08 의 Admin 운영값이다. +class ZapButton extends StatefulWidget { + const ZapButton({ + super.key, + required this.zapPop, + required this.myPop, + required this.onZap, + this.blockedReason, + this.isHeroTime = false, + this.onContribute, + this.onItem, + this.round = false, + this.showLabel = true, + }); + + /// 한 번에 기여할 POP. Admin 운영값. + final int zapPop; + + /// 내 활동 POP 잔액. 모자라면 누를 수 없다. + final int myPop; + + /// HERO 참여는 개인 POP 차감이 아니라 선물 수령 화면으로 진입한다. + final bool isHeroTime; + + /// 전송 중·소속 없음 등 입력을 막는 사유. Eclipse 방어와는 무관하다. + final String? blockedReason; + + final VoidCallback onZap; + final VoidCallback? onContribute; + final VoidCallback? onItem; + + /// HOME 의 원형 Zap — Figma 사본 `252:159354` 「Frame 1984081111」(52 원 · 청록→파랑 + /// 그라디언트 · 번개 + 「기여」). 옵션은 위에 기여, 아래 줄에 닫기·아이템(`Frame 1984081131`). + final bool round; + + /// 원형 기본 상태의 「기여」 라벨. 성운 상태 사본(`344:213778`)은 번개만 있다. + final bool showLabel; + + /// 명세의 「2초간 롱프레싱」. + static const longPress = Duration(seconds: 2); + + @override + State createState() => _ZapButtonState(); +} + +class _ZapButtonState extends State { + bool _optionsOpen = false; + + @override + void didUpdateWidget(ZapButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isHeroTime != widget.isHeroTime) _optionsOpen = false; + } + + bool get _canZap => + widget.blockedReason == null && (widget.isHeroTime || widget.myPop >= widget.zapPop); + + @override + Widget build(BuildContext context) => AnimatedSize( + duration: const Duration(milliseconds: 180), + alignment: Alignment.bottomCenter, + child: widget.round + ? (_optionsOpen ? _roundOptions(context) : _roundBasic(context)) + : (_optionsOpen ? _options(context) : _basic(context)), + ); + + Widget _roundBasic(BuildContext context) => RawGestureDetector( + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(duration: ZapButton.longPress), + (recognizer) => + recognizer.onLongPress = () => + setState(() => _optionsOpen = true), + ), + }, + // Tooltip 은 쓰지 않는다 — 자체 길게 누르기가 2초 옵션 전환을 가로챈다. + child: Semantics( + label: _canZap + ? (widget.isHeroTime + ? 'ZAP+ · HERO TIME' + : 'ZAP ${widget.zapPop} POP') + : (widget.blockedReason ?? context.l10n.zapNotEnoughPop), + child: Opacity( + opacity: _canZap ? 1 : 0.4, + child: _RoundZap( + icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic), + label: widget.isHeroTime ? 'ZAP+' : context.l10n.commonContribute, + fill: _zapGradient, + glow: true, + onTap: _canZap ? widget.onZap : null, + showLabel: widget.showLabel, + ), + ), + ), + ); + + Widget _roundOptions(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RoundZap( + icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic), + label: context.l10n.commonContribute, + fill: _zapGradient, + glow: true, + onTap: widget.onContribute, + ), + const SizedBox(height: AppSpace.s5), + // 사본의 왼쪽 「자동」 자리는 비운다(클래스 주석 — 별첨 B 에 없는 기능). + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 52 + AppSpace.s6), + _RoundZap( + icon: const ImageIcon( + AppIcons.x, + size: 24, + color: AppColors.onCosmic, + ), + label: context.l10n.commonClose, + showLabel: false, + ring: true, + onTap: () => setState(() => _optionsOpen = false), + ), + const SizedBox(width: AppSpace.s6), + _RoundZap( + icon: const ImageIcon(_item, size: 20, color: AppColors.onCosmic), + label: context.l10n.commonItem, + onTap: widget.onItem, + ), + ], + ), + ], + ); + + Widget _basic(BuildContext context) => RawGestureDetector( + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(duration: ZapButton.longPress), + (recognizer) => + recognizer.onLongPress = () => + setState(() => _optionsOpen = true), + ), + }, + child: FilledButton.icon( + onPressed: _canZap ? widget.onZap : null, + style: FilledButton.styleFrom( + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmicAccent, + minimumSize: const Size.fromHeight(AppSizing.touchMinApp), + ), + icon: const Icon(Icons.bolt), + label: Text( + _canZap + ? widget.isHeroTime ? 'ZAP+ · HERO TIME' : 'ZAP ${widget.zapPop} POP' + : (widget.blockedReason ?? context.l10n.zapNotEnoughPop), + ), + ), + ); + + Widget _options(BuildContext context) => Container( + padding: const EdgeInsets.all(AppSpace.s2), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceRaised, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.cosmicOutline), + ), + child: Row( + children: [ + _Option( + icon: Icons.volunteer_activism_outlined, + label: context.l10n.commonContribute, + onTap: widget.onContribute, + ), + _Option( + icon: Icons.card_giftcard_outlined, + label: context.l10n.commonItem, + onTap: widget.onItem, + ), + _Option( + icon: Icons.close, + label: context.l10n.commonClose, + onTap: () => setState(() => _optionsOpen = false), + ), + ], + ), + ); +} + +class _Option extends StatelessWidget { + const _Option({required this.icon, required this.label, this.onTap}); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + constraints: const BoxConstraints(minHeight: AppSizing.touchMinApp), + padding: const EdgeInsets.symmetric(vertical: AppSpace.s2), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: AppColors.onCosmic), + const SizedBox(height: 2), + Text(label, style: t.labelSmall), + ], + ), + ), + ), + ); + } +} + +/// 원형 Zap 한 칸 — 52 원 · 패딩 8 · 아이콘 20 위 10pt 라벨(42dot Medium). +class _RoundZap extends StatelessWidget { + const _RoundZap({ + required this.icon, + required this.label, + this.onTap, + this.fill, + this.glow = false, + this.ring = false, + this.showLabel = true, + }); + + final Widget icon; + final String label; + final VoidCallback? onTap; + final Gradient? fill; + final bool glow; + final bool ring; + final bool showLabel; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: fill, + color: fill == null && !ring ? const Color(0xFF050505) : null, + border: ring ? Border.all(color: const Color(0xFF4F98C8)) : null, + boxShadow: glow + ? const [BoxShadow(color: Color(0xFF547BD4), blurRadius: 6)] + : null, + ), + alignment: Alignment.center, + child: showLabel + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + Text( + label, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 10, + fontWeight: FontWeight.w500, + height: 1.6, + color: AppColors.onCosmic, + ), + ), + ], + ) + : icon, + ), + ), + ); +} diff --git a/app/lib/features/galaxy/widgets/zap_feedback.dart b/app/lib/features/galaxy/widgets/zap_feedback.dart new file mode 100644 index 0000000..de99f97 --- /dev/null +++ b/app/lib/features/galaxy/widgets/zap_feedback.dart @@ -0,0 +1,209 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/planet_fx_scope.dart'; +import '../../../core/format.dart'; +import '../galaxy_data.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// v1.5 §3: 입력 에너지는 즉시, 흡수·기여 결과는 서버 거래 확정 후에만 표시한다. +class ZapFeedback extends StatefulWidget { + const ZapFeedback({ + super.key, + required this.touchSerial, + required this.pending, + required this.result, + required this.source, + required this.target, + required this.radius, + }); + + final int touchSerial; + final bool pending; + final ZapResult? result; + final Offset source; + final Offset target; + final double radius; + + @override + State createState() => _ZapFeedbackState(); +} + +class _ZapFeedbackState extends State + with TickerProviderStateMixin { + late final _travel = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 380), + ); + late final _absorb = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + final _seen = {}; + bool _showResult = false; + + @override + void didUpdateWidget(ZapFeedback old) { + super.didUpdateWidget(old); + if (widget.touchSerial != old.touchSerial) { + _showResult = false; + _travel.forward(from: 0); + } + final result = widget.result; + if (result != null && result.txId.isNotEmpty && _seen.add(result.txId)) { + _showResult = true; + _absorb.forward(from: 0); + } + } + + @override + void dispose() { + _travel.dispose(); + _absorb.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final reduced = MediaQuery.disableAnimationsOf(context); + final strongFx = PlanetFxScope.of(context)?.busy ?? false; + return AnimatedBuilder( + animation: Listenable.merge([_travel, _absorb]), + builder: (context, _) { + final result = widget.result; + final showResult = _showResult && !widget.pending && _absorb.value < 1; + return Stack( + children: [ + if (!reduced && !strongFx) + Positioned.fill( + child: CustomPaint( + painter: _ZapPainter( + source: widget.source, + target: widget.target, + radius: widget.radius, + travel: _travel.value, + travelling: _travel.isAnimating, + absorption: showResult ? _absorb.value : null, + ), + ), + ), + if (widget.pending || showResult) + Positioned( + left: 16, + right: 16, + bottom: 128, + child: Center( + child: Semantics( + liveRegion: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.cosmicSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Text( + widget.pending + ? context.l10n.zapApplying + : result?.contributedPop == null + ? context.l10n.zapDone + : context.l10n.zapDonePop( + formatPop(result!.contributedPop!), + ), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _ZapPainter extends CustomPainter { + const _ZapPainter({ + required this.source, + required this.target, + required this.radius, + required this.travel, + required this.travelling, + required this.absorption, + }); + final Offset source; + final Offset target; + final double radius; + final double travel; + final bool travelling; + final double? absorption; + + @override + void paint(Canvas canvas, Size size) { + const color = AppColors.cosmicAccent; + if (travelling) { + final t = Curves.easeOutCubic.transform(travel); + // 짧은 직선 이동, 잔상은 끝점 근처 3개로 제한한다. + for (var i = 2; i >= 0; i--) { + final p = Offset.lerp(source, target, (t - i * 0.04).clamp(0.0, 1.0))!; + canvas.drawCircle( + p, + 5.0 - i, + Paint() + ..color = color.withValues( + alpha: (1 - i * 0.25) * (1 - travel * 0.5), + ), + ); + } + } + final a = absorption; + if (a == null || a > 0.5) return; + final t = a * 2; + canvas.drawCircle( + target, + radius * (1 + t * 0.5), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 * (1 - t) + 0.5 + ..color = color.withValues(alpha: 0.75 * (1 - t)), + ); + canvas.drawCircle( + target, + radius * 1.5, + Paint() + ..shader = RadialGradient( + colors: [ + color.withValues(alpha: 0.3 * (1 - t)), + color.withValues(alpha: 0), + ], + ).createShader(Rect.fromCircle(center: target, radius: radius * 1.5)), + ); + for (var i = 0; i < 8; i++) { + final angle = i * math.pi / 4; + final p = + target + + Offset(math.cos(angle), math.sin(angle)) * radius * (1.5 - t * 0.5); + canvas.drawCircle( + p, + 2 * (1 - t), + Paint()..color = color.withValues(alpha: 1 - t), + ); + } + } + + @override + bool shouldRepaint(_ZapPainter old) => + old.source != source || + old.target != target || + old.radius != radius || + old.travel != travel || + old.travelling != travelling || + old.absorption != absorption; +} diff --git a/app/lib/features/galaxy/zap_controller.dart b/app/lib/features/galaxy/zap_controller.dart new file mode 100644 index 0000000..e702a34 --- /dev/null +++ b/app/lib/features/galaxy/zap_controller.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import '../../core/data/backend.dart'; +import 'galaxy_data.dart'; +import '../../core/i18n/app_locale.dart'; + +/// HOME과 Galaxy의 동일한 기여 거래: 연타 합산, 확정 결과, 동일 키 재확인. +class ZapController extends ChangeNotifier { + ZapController(this.repository, {required this.onFailure}); + final GalaxyRepository repository; + final ValueChanged onFailure; + int? balance; + int pending = 0; + bool sending = false; + int touchSerial = 0; + ZapResult? result; + Timer? _burst; + bool _disposed = false; + ({String fandomId, int count, String key})? _batch; + bool get needsRetry => _batch != null && !sending; + + void tap(String fandomId) { + if (_disposed || sending || _batch != null || fandomId.isEmpty) return; + if (pending == 0) HapticFeedback.lightImpact(); + pending++; + touchSerial++; + notifyListeners(); + _burst ??= Timer(const Duration(milliseconds: 450), () => _flush(fandomId)); + } + + void retry() { + if (_batch case final batch?) _flush(batch.fandomId); + } + + Future _flush(String fandomId) async { + if (_disposed || sending) return; + final batch = + _batch ?? + ( + fandomId: fandomId, + count: pending, + key: 'z${DateTime.now().microsecondsSinceEpoch}', + ); + if (batch.count == 0) return; + _burst?.cancel(); + _burst = null; + _batch = batch; + sending = true; + pending = 0; + notifyListeners(); + try { + final receipt = await repository.zap( + fandomId: batch.fandomId, + count: batch.count, + clientSeq: batch.key, + ); + if (_disposed) return; + if (receipt.txId.isEmpty) throw StateError('Missing ZAP receipt'); + if (receipt.balanceAfter >= 0) balance = receipt.balanceAfter; + result = receipt; + _batch = null; + } catch (error) { + if (_disposed) return; + onFailure(switch (error) { + BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop, + BackendException(code: 'E-POP-02') => appL10n.zapFailLedger, + BackendException(code: 'E-AUTH-03') => appL10n.authLoginRequired, + _ => appL10n.zapFailRetry, + }); + } finally { + if (!_disposed) { + sending = false; + notifyListeners(); + } + } + } + + @override + void dispose() { + _disposed = true; + _burst?.cancel(); + super.dispose(); + } +} diff --git a/app/lib/features/home/growth_complete_screen.dart b/app/lib/features/home/growth_complete_screen.dart new file mode 100644 index 0000000..7be43a3 --- /dev/null +++ b/app/lib/features/home/growth_complete_screen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import 'home_data.dart'; +import 'home_screen.dart' show HomeHeader; +import 'planet_create_screen.dart'; +import 'widgets/home_hero.dart'; + +/// 성장 완료 — Figma 사본 「홈_성장 완료」(`344:213778`). +/// +/// 사본에서는 따로 된 페이지가 아니라 **HOME 탭의 성운 상태**다. HOME 은 서버 Stage 가 +/// 0(성운)이면 같은 [HomeHero] 성운 모드를 그리고, 이 화면은 그 상태를 값으로 직접 받아 +/// 띄운다(검수 대조용 입구). +/// +/// 정책 근거는 정책 원장 B-10 — **10명 이상 + 1,000 POP 이상 두 조건을 모두** 채워야 +/// 정식 행성(Stage1)이 된다. 한쪽만 채우면 성운을 눌러도 행성 생성으로 가지 않는다. +class GrowthCompleteScreen extends StatelessWidget { + const GrowthCompleteScreen({ + super.key, + this.nebulaName = 'BTS_RM', + this.nickname = 'BTS_Hong', + this.pop = 0, + this.members = 12, + this.requiredMembers = 10, + this.contributedPop = 1240, + this.requiredPop = 1000, + this.isFounder = true, + }); + + final String nebulaName; + + /// 헤더 아래 「{닉네임} 님의」. + final String nickname; + + /// 헤더의 내 활동 POP. + final int pop; + final int members; + final int requiredMembers; + final int contributedPop; + final int requiredPop; + + /// 이 성운을 만든 창립자인가 — 탄생 안내는 창립자에게만 뜬다(Q-PN-01 A안). + final bool isFounder; + + @override + Widget build(BuildContext context) { + final growth = PlanetGrowth( + stage: 0, + fandomName: nebulaName, + contributorName: nickname, + contributedPop: contributedPop, + growthPercent: requiredPop <= 0 + ? null + : (contributedPop * 100 / requiredPop).clamp(0, 100).round(), + currentPop: contributedPop, + requiredPop: requiredPop, + members: members, + requiredMembers: requiredMembers, + isFounder: isFounder, + ); + return Scaffold( + body: SingleChildScrollView( + child: Stack( + children: [ + HomeHero( + growth: growth, + fandomId: nebulaName, + onBirth: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlanetCreateScreen(nebulaName: nebulaName), + ), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 0, + right: 0, + child: HomeHeader(pop: pop), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/home/home_data.dart b/app/lib/features/home/home_data.dart new file mode 100644 index 0000000..82720c2 --- /dev/null +++ b/app/lib/features/home/home_data.dart @@ -0,0 +1,529 @@ +/// 홈 화면이 그리는 값의 형태. +/// +/// **화면과 데이터를 갈라 둔 이유** — 서버가 아직 없다. Firestore 연동은 5주차부터이고 +/// 그때 [HomeRepository] 구현체만 갈아 끼우면 화면은 손대지 않는다. 지금 화면 안에 +/// 숫자를 박아 두면 그 시점에 화면을 다시 짜야 한다. +/// +/// 값의 근거는 Figma `03_Wireframe` 「홈」(`252:159354`)이다. 시연용 표본이며 +/// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다. +library; + +import '../../core/i18n/app_locale.dart'; + +/// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §P-08. +class PlanetGrowth { + const PlanetGrowth({ + this.stage, + this.currentSfpi, + this.requiredSfpi, + this.members, + this.requiredMembers, + this.nebulaGoalPassed = false, + this.isFounder = false, + required this.fandomName, + required this.contributorName, + required this.contributedPop, + required this.growthPercent, + required this.currentPop, + required this.requiredPop, + }); + + /// 서버 팬덤 Stage 0~6. 누락된 단계는 추정하지 않는다. + final int? stage; + final String fandomName; + + /// 직전 기여자. 「BTS_Hong 님의 1,230 POP 기여로 …」 문구에 쓴다. + final String contributorName; + final int contributedPop; + + bool get hasRecentContribution => + contributedPop > 0 && contributorName.trim().isNotEmpty; + + /// 0~100 POP 진행률. 전체 성장 조건의 충족률은 아니다. + final int? growthPercent; + + /// 다음 Stage 진입까지의 누적 진행도. 정책 설계서 §P-08 은 POP 과 sFPI 동시 충족이라 + /// 정한다. 조건 누락은 null이며 개인 기여량으로 대체하지 않는다. + final num? currentPop; + final int? requiredPop; + final num? currentSfpi; + final num? requiredSfpi; + + /// 성운(Stage0)의 창립 멤버 수와 전환 기준 인원. 정책 원장 B-10 — 성운은 **10명 이상 + + /// 1,000 POP 이상**을 동시에 채워야 정식 행성(Stage1)이 된다. 기준값이 서버에서 오지 + /// 않으면 null 이며 정책 수치로 채우지 않는다. + final int? members; + final int? requiredMembers; + + /// 성운의 목표 기간(기본 7일)이 지났지만 전환은 여전히 열려 있는가 — 해석 확인 요청 Q-PN-02 + /// 권고 A안. 남은 일수 대신 「목표 기간이 지났어요 · 조건을 채우면 …」를 보인다. + final bool nebulaGoalPassed; + + /// 내가 이 성운을 만든 창립자(`fandoms.founderUid`)인가 — 해석 확인 요청 Q-PN-01 A안. + /// 탄생(형태 · 컬러 선택)은 창립자만 한다. 서버(`fn-planet-create`)도 같은 검사를 한다. + final bool isFounder; + + /// 성운 단계인가(B-08 Stage0 / Pre-Planet). + bool get isNebula => stage == 0; + + /// 성운 → 행성 전환 조건을 둘 다 채웠는가. 하나라도 모르면 채우지 않은 것으로 본다. + bool get nebulaReady => + isNebula && + _met(currentPop, requiredPop) == true && + _met(members, requiredMembers) == true; + + /// 조건 미조회와 실제 0을 구분한다. 승급 자체는 서버 확정으로만 처리한다. + String get popConditionLabel => _condition('POP', currentPop, requiredPop); + String get sfpiConditionLabel => + _condition('sFPI', currentSfpi, requiredSfpi); + String get memberConditionLabel => + _condition(appL10n.growthFounders, members, requiredMembers); + String get growthStatus => stage == 6 + ? appL10n.growthFinalStage + : _met(currentPop, requiredPop) == true && + _met(currentSfpi, requiredSfpi) == true + ? appL10n.growthPromotionPending + : appL10n.growthCheckConditions; + + double? get popRatio => + currentPop == null || requiredPop == null || requiredPop! <= 0 + ? null + : (currentPop! / requiredPop!).clamp(0.0, 1.0); + + /// 조건 충족 여부. 값을 모르면 null. + static bool? _met(num? current, num? required) { + if (current == null || + required == null || + required < 0 || + !current.isFinite || + !required.isFinite) + return null; + return current >= required; + } + + static String _condition(String name, num? current, num? required) => + switch (_met(current, required)) { + null => appL10n.growthConditionChecking(name), + true => appL10n.growthConditionMet(name), + false => appL10n.growthConditionUnmet(name), + }; +} + +/// 오늘의 퀘스트 한 줄. +enum QuestState { + /// 조건을 채웠고 보상을 아직 받지 않았다. + claimable, + + /// 진행 중. + inProgress, + + /// 보상까지 받았다. + done, +} + +class QuestSummary { + const QuestSummary({ + required this.title, + required this.rewardPop, + required this.state, + this.progressLabel, + this.id = '', + this.endsAt, + }); + + final String title; + + /// 보상 POP — 서버 지급액과 같은 원천. 못 읽으면 null. + final int? rewardPop; + final QuestState state; + + /// 「0/1,500 걸음」 같은 진행 표기. 없으면 표시하지 않는다. + final String? progressLabel; + + /// `quests` 문서 ID. 타임어택 카드가 상세로 바로 들어갈 때 쓴다. + final String id; + + /// 타임어택 마감(`quests.activeTo`). 있으면 카드가 남은 시간을 센다. + final DateTime? endsAt; +} + +/// AI Daily Briefing 구획 — 정책 원장 B-47 이 News 와 SNS 둘로 확정했다. +/// +/// *News/SNS 각각 0 건이면 해당 섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김* +enum BriefingSection { + news('News', 'NEWS'), + sns('SNS', 'SNS'); + + const BriefingSection(this.key, this.label); + + /// 서버 `briefings.section` 값. 대소문자까지 서버와 같아야 한다. + final String key; + + /// 구획 제목. + final String label; + + /// 서버 값에서 구획을 찾는다. 모르는 값은 null 이고 화면이 그 줄을 버린다 — + /// 정책이 정한 둘 밖의 섹션을 임의로 그리면 근거 없는 구획이 생긴다. + static BriefingSection? of(String key) { + for (final section in values) { + if (section.key == key) return section; + } + return null; + } +} + +/// AI Daily Briefing 한 줄 — 정책 원장 B-47 · B-48 · B-75. +/// +/// **문안은 발주사 Agent 엔진이 만든다.** B-48 이 「외부 Agent 엔진은 발주사 영역, +/// 저희 범위는 Candidate/Draft 수신 · Admin 승인 · 노출」로 확정했다. +/// +/// **5 요소가 전부 필수다.** B-75 — *각 항목에 출처명·게시/발행시각·원본 URL·원문보기·* +/// *AI 요약 표시를 제공합니다. 출처 확인 불가 AI 내용은 게시하지 않습니다.* 필드를 +/// 선택값으로 두면 메타가 빈 카드가 화면까지 내려오고, 그것이 곧 B-75 위반이다. +/// 서버도 같은 이유로 수신 단계에서 거부한다(`fn-briefing-ingest`). +/// +/// 화면 표시는 지금 제목 한 줄이다. 출처명·게시시각·AI 요약을 어디에 배치할지는 +/// 발주사가 HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후 +/// 정한다 — 임의로 보조행을 넣지 않는다. +class BriefingItem { + const BriefingItem({ + required this.rank, + required this.headline, + required this.section, + required this.sourceName, + required this.sourceAt, + required this.sourceUrl, + required this.articleUrl, + required this.aiSummary, + }); + + /// 구획 안에서의 순서. 구획마다 1 부터 시작한다. + final int rank; + + /// 카드에 뜨는 제목. + final String headline; + + final BriefingSection section; + + /// 출처명 — 언론사·계정명. + final String sourceName; + + /// 게시·발행 시각. + final DateTime sourceAt; + + /// 원본 URL. + final String sourceUrl; + + /// 원문보기 링크. 원본과 같을 수 있다. + final String articleUrl; + + /// AI 요약 본문. + final String aiSummary; +} + +/// HOT ARCHIVE — 진행 중인 캠페인 한 건. +class CampaignHighlight { + const CampaignHighlight({ + required this.authorName, + required this.achievedPercent, + required this.title, + required this.description, + required this.supporters, + required this.comments, + required this.likes, + }); + + final String authorName; + final int achievedPercent; + final String title; + final String description; + final int supporters; + final int comments; + final int likes; +} + +/// 인기 K-INSIDE 한 건. +class InsideDigest { + const InsideDigest({ + required this.authorName, + required this.timeAgo, + required this.bountyPop, + required this.title, + required this.body, + required this.answers, + }); + + final String authorName; + final String timeAgo; + + /// 현상금. 정책 설계서 §P-11 — 채택 기한은 아직 미확정이다. + final int bountyPop; + final String title; + final String body; + final int answers; +} + +/// 홈 한 화면이 필요로 하는 것 전부. +class HomeSnapshot { + const HomeSnapshot({ + required this.popBalance, + required this.liveTicker, + this.fandomId = '', + required this.growth, + required this.quests, + required this.briefings, + required this.campaign, + required this.insides, + this.items = const [], + }); + + /// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고 + /// 홈 상단에는 활동 POP 을 띄운다. + final int popBalance; + + /// 내 소속 팬덤. 이클립스 경보 배너와 라이브 띠가 **어느 팬덤을 구독할지**를 + /// 이 값으로 정한다. 비어 있으면 소속이 없다는 뜻이라 둘 다 뜨지 않는다. + final String fandomId; + + /// 상단 라이브 띠의 **폴백** 문구. 원천은 `fandoms/{id}/liveEvents` 구독이고 + /// (별첨 1 ① 시나리오 5) 이 문자열은 라이브 이벤트가 아직 없을 때만 쓴다. + /// `fandoms/{id}.liveTicker` 를 쓰는 서버 코드는 시드 스크립트뿐이다. + final String? liveTicker; + + final PlanetGrowth growth; + final List quests; + final List briefings; + final CampaignHighlight? campaign; + final List insides; + + /// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85) + /// `heroGiftProducts` 에서 온다. + final List items; +} + +/// 아이템(Hero Gift) 상품 한 줄 — 사본 `아이템 구입`(`252:160233`) 카드. +/// +/// 구매하면 구매자에게 기여 POP, 팬덤 Drop Pool 에 같은 수량의 일반 POP 이 생긴다 +/// (원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다. +class ItemProduct { + const ItemProduct({ + this.productId = '', + required this.name, + required this.description, + required this.dropPop, + required this.buyerPop, + required this.priceWon, + required this.owned, + }); + + /// 스토어 상품 ID(= Catalog 문서 ID). 표본은 비어 있다. + final String productId; + final String name; + final String description; + + /// 「N POP 전송」 — 팬덤 Drop Pool 에 생기는 일반 POP. + final int dropPop; + + /// 「구매자 +N POP」. + final int buyerPop; + final int priceWon; + + /// 보유 수량. + final int owned; +} + +/// 홈이 값을 얻어 오는 경로. +/// +/// 5주차 Firestore 연동 때 이 인터페이스의 구현체만 바꾼다. +abstract interface class HomeRepository { + Future load(); + Stream watchGrowth(String fandomId); + + /// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이 + /// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다. + Future<({String fandomId, bool primaryChanged})> createNebula(String name); + + /// 성운 → 정식 행성 탄생 — `fn-planet-create`. 형태 · 컬러 두 값만 보낸다(C-50). + Future createPlanet({ + required String fandomId, + required String planetType, + required String colorKey, + }); + + /// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy` + /// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets]. + Future nebulaTargets(); +} + +/// 성운 전환 목표 — POP · 창립 인원 · 목표 기간(일). +typedef NebulaTargets = ({int pop, int members, int days}); + +/// 정책 원장 B-10 확정값. `config/policy` 가 없을 때의 출발점이다. +const NebulaTargets defaultNebulaTargets = (pop: 1000, members: 10, days: 7); + +/// `config/policy` 문서에서 성운 목표를 읽는다. 키가 없거나 잘못된 값이면 기본값. +NebulaTargets nebulaTargetsFrom(Map? policy) { + int read(String key, int fallback) => switch (policy?[key]) { + num n when n.isFinite && n > 0 => n.toInt(), + _ => fallback, + }; + return ( + pop: read('nebulaMinPop', defaultNebulaTargets.pop), + members: read('nebulaMinMembers', defaultNebulaTargets.members), + days: read('nebulaDays', defaultNebulaTargets.days), + ); +} + +/// 서버가 붙기 전까지 쓰는 표본. +/// +/// **정책값이 아니다.** 화면이 어떤 모양으로 서는지 확인하기 위한 것이며, +/// 실제 수치는 수치표 v1.4 와 정책 설계서가 정한다. +class SampleHomeRepository implements HomeRepository { + const SampleHomeRepository(); + + @override + Stream watchGrowth(String fandomId) => const Stream.empty(); + + @override + Future nebulaTargets() async => defaultNebulaTargets; + + @override + Future<({String fandomId, bool primaryChanged})> createNebula( + String name, + ) async => (fandomId: 'bts', primaryChanged: true); + + @override + Future createPlanet({ + required String fandomId, + required String planetType, + required String colorKey, + }) async {} + + @override + // 브리핑 표본이 `sourceAt` 에 DateTime 을 담아 const 로 둘 수 없다. + Future load() async => HomeSnapshot( + popBalance: 12540, + fandomId: 'bts', + liveTicker: '[LIVE] 아미보이님의 아이템 사용으로 버프가 발동되었습니다!', + growth: PlanetGrowth( + fandomName: 'BTS', + contributorName: 'BTS_Hong', + contributedPop: 1230, + growthPercent: 67, + currentPop: 6270, + requiredPop: 10000, + ), + quests: [ + QuestSummary( + title: '오늘의 출석체크', + // 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다. + rewardPop: 10, + state: QuestState.claimable, + ), + QuestSummary( + title: '만보기', + rewardPop: 150, + state: QuestState.inProgress, + progressLabel: '0/1,500 걸음', + ), + ], + briefings: _sampleBriefings, + campaign: CampaignHighlight( + authorName: '다이브짱', + achievedPercent: 92, + title: '강남역 초대형 LED 광고', + description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.', + supporters: 48, + comments: 24, + likes: 82, + ), + // 사본 `아이템 구입` 표본(Admin 값 아님). + items: const [ + ItemProduct( + name: '베이직 드랍', + description: '소소하지만 확실한 팬덤 기여', + dropPop: 500, + buyerPop: 100, + priceWon: 1200, + owned: 11, + ), + ItemProduct( + name: '메가 드랍', + description: '팬덤의 열기를 달구는 강력한 화력', + dropPop: 2000, + buyerPop: 500, + priceWon: 3900, + owned: 10, + ), + ItemProduct( + name: '에픽 드랍', + description: '전설적인 영웅의 등장, 압도적 물량', + dropPop: 5000, + buyerPop: 1500, + priceWon: 8900, + owned: 10, + ), + ], + insides: [ + InsideDigest( + authorName: '아미보이', + timeAgo: '10분 전', + bountyPop: 500, + title: '화양연화 세계관 해석해주실 분?', + body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!', + answers: 3, + ), + InsideDigest( + authorName: '다이브짱', + timeAgo: '1시간 전', + bountyPop: 200, + title: '공방 신청 꿀팁 공유합니다.', + body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요', + answers: 8, + ), + InsideDigest( + authorName: '네누', + timeAgo: '3시간 전', + bountyPop: 100, + title: 'BTS 이 포즈 뭔가요?', + body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?', + answers: 8, + ), + ], + ); +} + +/// 브리핑 표본 — **정책값이 아니다.** B-75 5 요소를 전건 채워 두어 화면이 무엇을 +/// 받게 되는지 보인다. News 2 건 · SNS 1 건으로 구획 분기도 함께 확인된다. +final List _sampleBriefings = [ + BriefingItem( + rank: 1, + section: BriefingSection.news, + headline: 'NewJeans, 빌보드 ‘Women in Music’ 어워드 올해의 그룹상 수상 확정', + sourceName: '연합뉴스', + sourceAt: DateTime(2026, 9, 14, 8, 20), + sourceUrl: 'https://example.com/news/newjeans-billboard', + articleUrl: 'https://example.com/news/newjeans-billboard', + aiSummary: '빌보드가 올해의 그룹상 수상자를 확정해 발표했습니다.', + ), + BriefingItem( + rank: 2, + section: BriefingSection.news, + headline: 'NewJeans, ‘Super Shy’, 스포티파이 글로벌 차트 장기 집권 중', + sourceName: '스포티파이 차트', + sourceAt: DateTime(2026, 9, 14, 7, 5), + sourceUrl: 'https://example.com/news/super-shy-chart', + articleUrl: 'https://example.com/news/super-shy-chart', + aiSummary: '글로벌 차트 상위권을 여러 주 유지하고 있습니다.', + ), + BriefingItem( + rank: 1, + section: BriefingSection.sns, + headline: '버니즈를 위한 도쿄돔 팬미팅 비하인드 독점 공개 예정', + sourceName: '공식 X 계정', + sourceAt: DateTime(2026, 9, 14, 6, 40), + sourceUrl: 'https://example.com/sns/tokyo-dome', + articleUrl: 'https://example.com/sns/tokyo-dome', + aiSummary: '팬미팅 비하인드 영상 공개 일정이 예고되었습니다.', + ), +]; diff --git a/app/lib/features/home/home_onboarding_screen.dart b/app/lib/features/home/home_onboarding_screen.dart new file mode 100644 index 0000000..7a43dc1 --- /dev/null +++ b/app/lib/features/home/home_onboarding_screen.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'home_data.dart'; +import 'home_screen.dart' show HomeHeader; +import 'widgets/home_hero.dart'; + +/// 초기 사용자 홈 — Figma 사본 「홈_초기사용자」(`343:212939`). +/// +/// 사본에서는 **HOME 탭의 무소속 상태**다. HOME 은 소속 팬덤이 없으면(`fandomId` 가 +/// 비면) 같은 [HomeHero] 첫 사용자 모드를 그리고, 이 화면은 그 상태를 직접 띄운다. +/// 「+」 를 누르면 「행성 생성 (성운) / 행성 가입 / 취소」 시트가 뜬다. +class HomeOnboardingScreen extends StatelessWidget { + const HomeOnboardingScreen({ + super.key, + this.pop = 12540, + this.nickname = 'BTS_Hong', + this.onCreateNebula, + this.onJoin, + }); + + /// 헤더의 내 활동 POP. + final int pop; + + /// 헤더 아래 「{닉네임} 님의」. + final String nickname; + final VoidCallback? onCreateNebula; + final VoidCallback? onJoin; + + @override + Widget build(BuildContext context) => Scaffold( + body: SingleChildScrollView( + child: Stack( + children: [ + HomeHero( + growth: PlanetGrowth( + fandomName: '', + contributorName: nickname, + contributedPop: 0, + growthPercent: null, + currentPop: null, + requiredPop: null, + ), + unjoined: true, + onCreateNebula: onCreateNebula, + onJoin: onJoin, + ), + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 0, + right: 0, + child: HomeHeader(pop: pop), + ), + ], + ), + ), + ); +} diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart new file mode 100644 index 0000000..a91cc4a --- /dev/null +++ b/app/lib/features/home/home_repository_firestore.dart @@ -0,0 +1,365 @@ +import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp; + +import '../../core/data/backend.dart'; +import '../../core/data/invite_code.dart'; +import '../../core/data/server_clock.dart'; +import 'home_data.dart'; +import '../quest/quest_data.dart' show QuestType; +import '../../core/i18n/app_locale.dart'; + +/// 홈의 실 서버 구현 — 화면 대조 원장 F1. +/// +/// [SampleHomeRepository] 와 같은 인터페이스라 화면은 어느 쪽이 붙었는지 모른다. +/// 값의 출처만 표본에서 Firestore 로 바뀐다. +/// +/// 성장 조건은 팬덤 공동 상태와 Stage 마스터에서 읽는다. +/// 개인 멤버 기여량/개인 행성 Stage는 공동 성장으로 대체하지 않는다. +class FirestoreHomeRepository implements HomeRepository { + FirestoreHomeRepository(this._backend); + + final Backend _backend; + + @override + Stream watchGrowth(String fandomId) { + if (fandomId.isEmpty) return const Stream.empty(); + return _backend.db + .doc('fandoms/$fandomId') + .snapshots(includeMetadataChanges: true) + .where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites) + .asyncMap((s) => _growth(s.data() ?? const {}, fandomId)); + } + + @override + Future<({String fandomId, bool primaryChanged})> createNebula( + String name, + ) async { + final data = await _backend.call('fnNebulaCreate', {'name': name}); + final fandomId = data['fandomId']; + // ID 없이 성공으로 돌아오면 화면이 빈 ID 로 성운 상세를 연다 — 실패로 다룬다. + if (fandomId is! String || fandomId.isEmpty) { + throw BackendException('E-SYS-01', appL10n.nebulaCreateFailed); + } + return ( + fandomId: fandomId, + primaryChanged: data['primaryChanged'] != false, + ); + } + + @override + Future createPlanet({ + required String fandomId, + required String planetType, + required String colorKey, + }) => _backend.call('fnPlanetCreate', { + 'fandomId': fandomId, + 'planetType': planetType, + 'colorKey': colorKey, + }); + + @override + Future nebulaTargets() async { + try { + return nebulaTargetsFrom( + (await _backend.db.doc('config/policy').get()).data(), + ); + } catch (_) { + return defaultNebulaTargets; + } + } + + @override + Future load() async { + final uid = _backend.requireUid; + // 공유 링크 `?ref=` 용 — 기다리지 않는다. + InviteCode.ensure(_backend); + final db = _backend.db; + + final userSnap = await db.doc('users/$uid').get(); + final user = userSnap.data() ?? const {}; + final fandomId = user.strOr('fandomId'); + + // 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다. + final walletF = db.doc('users/$uid/wallet/main').get(); + final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').get(); + final questsF = db + .collection('quests') + .where('active', isEqualTo: true) + .limit(5) + .get(); + // 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에 + // 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`. + // 질의는 단말 시계 기준 1시간 여유로 넓게 받고, 마감 판정은 아래에서 서버 시각으로 한다. + // ponytail: 단말 시계가 1시간 넘게 빠르면 열린 창을 놓친다. 그때는 여유를 늘린다. + final timeAttacksF = fandomId.isEmpty + ? null + : db + .collection('quests') + .where('type', isEqualTo: 'timeattack') + .where('fandomId', isEqualTo: fandomId) + .where( + 'activeTo', + isGreaterThan: Timestamp.fromDate( + DateTime.now().subtract(const Duration(hours: 1)), + ), + ) + .limit(10) + .get(); + final progressF = db.collection('users/$uid/questProgress').get(); + // 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다. + final policyF = db.doc('config/policy').get(); + // 브리핑은 **게시된 당일분만** 읽는다 — 정책 원장 B-47 「당일 승인된 콘텐츠만 + // 노출 · 미승인 Draft 노출 0건 · 전일 콘텐츠 자동 재사용 없음」. 세 조건을 + // 질의에 두어 화면이 걸러 낼 것이 남지 않게 한다. 앱에서 거르면 초안이 기기에 + // 한 번 내려온 뒤에 지워지는 것이라 「노출 0건」이 아니다. + final briefingsF = db + .collection('briefings') + .where('fandomId', isEqualTo: fandomId) + .where('status', isEqualTo: 'published') + .where('dayKey', isEqualTo: kstDayKey()) + // **섹션별로 rank 를 매기므로 정렬 키가 둘이다.** `rank` 만으로 자르면 + // News 3 건 + SNS 1 건일 때 rank 3 두 개가 경쟁해 News 셋째 줄이 잘린다. + // 상한 6 은 두 구획 각 3 줄 기준이다(Figma 홈의 3 줄 × 구획 2). + .orderBy('section') + .orderBy('rank') + .limit(6) + .get(); + // 차단한 개설자의 캠페인은 건너뛴다 — 다섯 건을 읽어 첫 비차단 캠페인을 쓴다. + final campaignF = db + .collection('campaigns') + .where('status', isEqualTo: 'funding') + .orderBy('raisedPop', descending: true) + .limit(5) + .get(); + final blocksF = db.collection('users/$uid/blocks').get(); + // Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가 + // 결제 창을 열기 전에 막는다(`fn-iap-verify` 도 거부한다). + final itemsF = db + .collection('heroGiftProducts') + .where('active', isEqualTo: true) + .get(); + final insidesF = db + .collection('questions') + .orderBy('createdAt', descending: true) + .limit(3) + .get(); + + final wallet = (await walletF).data() ?? const {}; + final fandom = fandomF == null + ? const {} + : ((await fandomF).data() ?? const {}); + final timeAttacks = [ + for (final doc in (await timeAttacksF)?.docs ?? const []) + if (doc.data()['active'] != false) doc, + ]; + if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend); + final serverNow = ServerClock.now(); + final quests = [ + ...timeAttacks.where((d) { + final to = d.data()['activeTo']; + return to is Timestamp && to.toDate().isAfter(serverNow); + }), + // 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다. + ...(await questsF).docs.where((d) => d.data()['type'] != 'timeattack'), + ]; + final progress = (await progressF).docs; + final briefings = (await briefingsF).docs; + final blocked = {for (final d in (await blocksF).docs) d.id}; + final campaigns = (await campaignF).docs + .where((d) => !blocked.contains(d.data()['creatorUid'])) + .toList(); + final insides = (await insidesF).docs; + + final progressById = {for (final doc in progress) doc.id: doc.data()}; + final policy = (await policyF).data() ?? const {}; + + return HomeSnapshot( + popBalance: wallet.intOr('activityBalance'), + fandomId: fandomId, + liveTicker: fandom.strOr('liveTicker').isEmpty + ? null + : fandom.strOr('liveTicker'), + growth: await _growth(fandom, fandomId), + quests: [ + for (final doc in quests) + _quest(doc.id, doc.data(), progressById[doc.id], policy), + ], + briefings: [for (final doc in briefings) ?_briefing(doc.data())], + campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()), + insides: [for (final doc in insides) _inside(doc.data())], + items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())], + ); + } + + /// 문서 ID 가 스토어 상품 ID 다. 수치는 Admin 저장값 그대로(B-84 · 환율 재계산 없음). + ItemProduct _item(String productId, Map data) => ItemProduct( + productId: productId, + name: data.strOr('name', productId), + description: data.strOr('description'), + dropPop: data.intOr('dropPop'), + buyerPop: data.intOr('buyerPop'), + priceWon: data.intOr('priceWon'), + // 구매 즉시 Drop 이 열려 보유 재고가 생기지 않는다. + owned: 0, + ); + + /// 브리핑 한 줄 — 정책 원장 B-75 5 요소가 전건 있어야 그린다. + /// + /// **하나라도 비면 그 줄을 버린다.** 서버가 수신 단계에서 이미 거부하지만 + /// (`fn-briefing-ingest`), Admin 수동 편집이나 예전 스키마 문서가 섞일 수 있다. + /// 여기서 통과시키면 출처가 빈 카드가 화면에 뜨고 그것이 B-75 위반이다. + /// 섹션이 News·SNS 밖이면 정책이 정한 구획이 아니므로 역시 버린다. + BriefingItem? _briefing(Map data) { + final section = BriefingSection.of(data.strOr('section')); + final headline = data.strOr('headline'); + final sourceName = data.strOr('sourceName'); + final sourceUrl = data.strOr('sourceUrl'); + final articleUrl = data.strOr('articleUrl'); + final aiSummary = data.strOr('aiSummary'); + final sourceAt = data['sourceAt']; + if (section == null || + headline.isEmpty || + sourceName.isEmpty || + sourceUrl.isEmpty || + articleUrl.isEmpty || + aiSummary.isEmpty || + sourceAt == null) { + return null; + } + return BriefingItem( + rank: data.intOr('rank', 1), + headline: headline, + section: section, + sourceName: sourceName, + sourceAt: data.dateOr('sourceAt', DateTime.now()), + sourceUrl: sourceUrl, + articleUrl: articleUrl, + aiSummary: aiSummary, + ); + } + + Future _growth( + Map fandom, + String fandomId, + ) async { + final stage = switch (fandom['stage']) { + int n when n >= 0 && n <= 6 => n, + _ => null, + }; + // Stage0의 탄생은 별도의 멤버/POP 조건이다. Stage1 마스터의 0으로 승급을 추정하지 않는다. + final next = stage != null && stage > 0 && stage < 6 + ? (await _backend.db.doc('planetStages/${stage + 1}').get()).data() + : null; + int? validInt(dynamic value) => value is int && value >= 0 ? value : null; + num? validNumber(dynamic value) => + value is num && value.isFinite && value >= 0 ? value : null; + final current = validNumber(fandom['growthPop']); + final required = validInt(next?['requiredPop']); + // 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다. + final policy = stage == 0 + ? (await _backend.db.doc('config/policy').get()).data() + : null; + final targets = stage == 0 ? nebulaTargetsFrom(policy) : null; + final goal = targets?.pop ?? required; + // Q-PN-02 — 기간이 지난 성운은 운영 설정이 막지 않는 한 조건만 채우면 탄생한다(서버와 같은 판정). + final deadline = fandom['nebulaDeadline']; + final goalPassed = + stage == 0 && + deadline is Timestamp && + ServerClock.now().isAfter(deadline.toDate()) && + policy?['nebulaConversionDeadlineEnforced'] != true; + return PlanetGrowth( + stage: stage, + fandomName: fandom.strOr('name', fandomId), + // 기존 데이터에 이메일 형태가 남아 있어도 가린다. + contributorName: fandom.containsKey('lastContributorName') + ? publicName(fandom['lastContributorName']) + : '', + contributedPop: fandom.intOr('lastContributedPop'), + growthPercent: current != null && goal != null && goal > 0 + ? ((current / goal) * 100).clamp(0, 100).round() + : null, + currentPop: current, + requiredPop: goal, + currentSfpi: validNumber(fandom['sfpi']), + members: validInt(fandom['memberCount']), + requiredMembers: targets?.members, + // Q-PN-01 A안 — 탄생은 창립자만. + isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid, + requiredSfpi: validNumber(next?['requiredSfpi']), + nebulaGoalPassed: goalPassed, + ); + } + + QuestSummary _quest( + String id, + Map quest, + Map? progress, + Map policy, + ) { + final done = + (progress?.intOr('count') ?? 0) >= quest.intOr('dailyLimit', 1); + final started = (progress?.intOr('count') ?? 0) > 0; + return QuestSummary( + title: quest.strOr('title'), + rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), + state: done + ? QuestState.done + : started + ? QuestState.claimable + : QuestState.inProgress, + progressLabel: quest.strOr('progressLabel').isEmpty + ? null + : quest.strOr('progressLabel'), + id: id, + endsAt: quest['type'] == 'timeattack' && quest['activeTo'] is Timestamp + ? (quest['activeTo'] as Timestamp).toDate() + : null, + ); + } + + CampaignHighlight _campaign(Map data) { + // 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다. + final goal = data.intOr('targetPop', data.intOr('goalPop')); + final raised = data.intOr('raisedPop'); + return CampaignHighlight( + authorName: publicName(data['creatorName'] ?? data['authorName']), + achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(), + title: data.strOr('title'), + description: data.strOr('summary', data.strOr('description')), + supporters: data.intOr('donorCount'), + comments: data.intOr('commentCount'), + likes: data.intOr('likeCount'), + ); + } + + InsideDigest _inside(Map data) { + final created = data.dateOr('createdAt', DateTime.now()); + return InsideDigest( + authorName: publicName(data['creatorName'] ?? data['authorName']), + timeAgo: _timeAgo(created), + bountyPop: data.intOr('bountyPop'), + title: data.strOr('title'), + body: data.strOr('body'), + answers: data.intOr('answerCount'), + ); + } +} + +/// 상대 시각. 화면이 「10분 전」 형태를 그대로 쓰므로 여기서 문자열로 만든다. +String _timeAgo(DateTime at) { + final gap = DateTime.now().difference(at); + if (gap.inMinutes < 1) return appL10n.timeJustNow; + if (gap.inHours < 1) return appL10n.timeMinutesAgo('${gap.inMinutes}'); + if (gap.inDays < 1) return appL10n.timeHoursAgo('${gap.inHours}'); + return appL10n.timeDaysAgo('${gap.inDays}'); +} + +/// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다. +/// +/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이 +/// 안 보인다. 브리핑은 한국 시간 기준 하루다. +String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc()) + .toUtc() + .add(const Duration(hours: 9)) + .toIso8601String() + .substring(0, 10); diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index 210bc76..c5b50ec 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -1,15 +1,694 @@ +// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다. +export '../../core/format.dart' show formatPop; + +import '../planet/planet_history_screen.dart'; +import '../galaxy/widgets/galaxy_mini_map.dart'; +import '../galaxy/galaxy_screen.dart'; +import '../galaxy/galaxy_data.dart'; +import '../galaxy/zap_controller.dart'; +import '../planet/hero_participation_sheet.dart'; +import '../planet/planet_data.dart'; +import '../planet/planet_detail_screen.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; -import '../../core/ui/tab_placeholder.dart'; +import '../../core/format.dart'; +import '../../core/i18n/app_locale.dart'; +import '../../core/theme/tokens.dart'; +import '../../core/data/planet_visual_state.dart'; +import '../../core/ui/live_stream.dart'; +import '../../core/ui/section_header.dart'; +import '../eclipse/eclipse_data.dart'; +import '../eclipse/widgets/eclipse_banner.dart'; +import '../eclipse/widgets/live_proof.dart'; +import 'home_data.dart'; +import 'nebula_create_screen.dart'; +import 'planet_create_screen.dart'; +import 'widgets/briefing_list.dart'; +import 'widgets/campaign_card.dart'; +import 'widgets/home_hero.dart'; +import 'widgets/item_sheet.dart'; +import 'widgets/inside_card.dart'; +import 'widgets/live_ticker.dart'; +import '../campaign/campaign_data.dart' show CampaignRepository; +import '../campaign/campaign_list_screen.dart'; +import '../notification/notification_screen.dart'; +import '../quest/quest_data.dart' show QuestRepository; +import '../quest/quest_list_screen.dart'; +import '../quest/quest_detail_screen.dart'; +import 'home_repository_firestore.dart' show kstDayKey; +import 'widgets/quest_row.dart'; +import 'widgets/briefing_share.dart'; -/// HOME 탭 루트 — S-H-HOME 오늘의 퀘스트. -class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); +/// HOME 탭 루트 — Figma `03_Wireframe` 「홈」(`252:159354`). +/// +/// 화면 프레임은 `252:159354` 다. 그 아래 `252:159361` 이하가 이 화면의 구성 +/// 요소이며, 화면 대조 원장은 프레임 번호로 화면을 셈한다. +/// +/// 화면 대조 원장 F1 의 1번 화면이다. 구성 순서는 Figma 를 따른다 — +/// 잔액 · 라이브 띠 · 행성 성장 · TODAY'S QUEST · AI Daily Briefing · +/// HOT ARCHIVE · 인기 K-INSIDE. +/// +/// 값은 [HomeRepository] 가 준다. 지금은 [SampleHomeRepository] 라 서버 없이 뜨고, +/// 5주차 Firestore 연동 때 구현체만 바꾼다. +class HomeScreen extends StatefulWidget { + const HomeScreen({ + super.key, + this.watchPlanet = noPlanetWatch, + this.planetRepository, + this.galaxyRepository, + required this.repository, + required this.questRepository, + required this.eclipse, + this.notifications = const SampleNotificationRepository(), + this.campaignRepository, + }); + + final HomeRepository repository; + final PlanetWatch watchPlanet; + final PlanetRepositoryFactory? planetRepository; + final GalaxyRepository? galaxyRepository; + + /// F4 로 넘겨줄 저장소. 홈이 퀘스트 화면을 여는 유일한 입구라, 여기서 받지 않으면 + /// 퀘스트 화면이 스스로 표본을 만들게 된다(저장소 선택은 repositories.dart 한 곳). + final QuestRepository questRepository; + + /// HOT ARCHIVE 로 넘겨줄 캠페인 저장소. null 이면 목록이 표본을 쓴다. + final CampaignRepository? campaignRepository; + + /// 이클립스 경보 배너와 라이브 띠의 원천. 저장소 선택은 [Repositories] 한 곳에서만 + /// 하므로(공통 규칙) 화면이 표본을 기본값으로 만들지 않는다. + final EclipseRepository eclipse; + + /// 헤더의 알림 입구가 여는 알림함(F0)의 저장소. + final NotificationRepository notifications; @override - Widget build(BuildContext context) => const TabPlaceholder( - tab: 'HOME', - screenId: 'S-H-HOME', - screenName: '오늘의 퀘스트', + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late Future _snapshot; + Future? _galaxy; + ZapController? _zap; + + void _onZapChanged() => setState(() {}); + + @override + void dispose() { + _zap?.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _snapshot = widget.repository.load(); + if (widget.galaxyRepository case final repository?) { + _galaxy = repository.load(); + _zap = ZapController( + repository, + onFailure: (message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }, + )..addListener(_onZapChanged); + } + } + + /// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다. + Future _createThenReload(Widget screen) async { + final created = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => screen), + ); + if (created == null || !mounted) return; + if (widget.galaxyRepository case final repository?) { + setState(() { _galaxy = repository.load(); }); + } + await _refresh(); + } + + Future _refresh() async { + final next = widget.repository.load(); + setState(() => _snapshot = next); + await next; + } + + @override + Widget build(BuildContext context) => Scaffold( + // 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서 + // 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다. + body: FutureBuilder( + future: _snapshot, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + // 원인이 화면에 보이지 않으므로 로그로 남긴다 — adb logcat | grep '\[HOME\]' + debugPrint('[HOME] load failed: ${snap.error}'); + return SafeArea(child: _LoadFailed(onRetry: _refresh)); + } + return _HomeBody( + data: snap.data!, + galaxy: _galaxy, + zap: _zap, + repository: widget.repository, + watchPlanet: widget.watchPlanet, + planetRepository: widget.planetRepository, + onRefresh: _refresh, + onCreate: _createThenReload, + questRepository: widget.questRepository, + campaignRepository: widget.campaignRepository, + eclipse: widget.eclipse, + notifications: widget.notifications, + ); + }, + ), + ); +} + +/// 히어로 위에 얹는 헤더 — Figma 홈 상단 「Fancake 로고 · POP 알약 · 알림」. +/// 브랜드는 공통 기준 4항대로 FANUZU 다. +/// 라이브 띠 한 자리. 스트림에 값이 오면 그 문구를, 없으면 폴백을 그린다. +/// +/// 폴백을 남겨 둔 이유는 시드 데이터로 도는 목 모드와 옛 문서 때문이다. 둘 다 +/// 없으면 띠 자체를 접는다 — 빈 알약이 홈 상단에 남으면 고장으로 읽힌다. +class _LiveTickerSlot extends StatelessWidget { + const _LiveTickerSlot({ + required this.eclipse, + required this.fandomId, + required this.fallback, + }); + + final EclipseRepository eclipse; + final String fandomId; + final String? fallback; + + @override + Widget build(BuildContext context) => LiveStreamBuilder>( + key: ValueKey('live-$fandomId'), + // 띠는 한 줄만 그리므로 최신 몇 건만 받는다. + create: () => fandomId.isEmpty + ? Stream.value(const []) + : eclipse.liveEvents(fandomId: fandomId, limit: 5), + builder: (context, events) { + final latest = (events == null || events.isEmpty) + ? fallback + : liveEventMessage(events.first); + if (latest == null) return const SizedBox.shrink(); + return LiveTicker(text: latest); + }, + ); +} + +/// 홈 헤더 — 성장 완료 · 첫 사용자 상태 화면도 같은 헤더를 쓴다. +class HomeHeader extends StatelessWidget { + const HomeHeader({ + super.key, + required this.pop, + this.notifications = const SampleNotificationRepository(), + this.items = const [], + this.fandomId = '', + }); + + final int pop; + final NotificationRepository notifications; + + /// 소속 팬덤. 비었으면 Hero Gift 결제 창을 열지 않는다(Drop 을 열 곳이 없다). + final String fandomId; + + /// 지갑 아이콘이 여는 「아이템 구입」 시트의 상품. + final List items; + + @override + Widget build(BuildContext context) => Padding( + // 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개. + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6), + child: SizedBox( + height: 42, + child: Row( + children: [ + const Text( + 'FANUZU', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 20, + fontWeight: FontWeight.w900, + height: 1.4, + letterSpacing: -0.5, + color: AppColors.cosmicAccent, + ), + ), + const Spacer(), + _PopBalance(value: pop), + IconButton( + constraints: const BoxConstraints.tightFor(width: 42, height: 42), + padding: EdgeInsets.zero, + icon: const Image( + image: AssetImage('assets/figma/icon-wallet.png'), + width: 42, + height: 42, + ), + // 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트. + onPressed: () => + showItemSheet( + context, + items: items, + purchase: true, + fandomId: fandomId, + ), + tooltip: context.l10n.homeItemsTooltip, + ), + IconButton( + constraints: const BoxConstraints.tightFor(width: 42, height: 42), + padding: EdgeInsets.zero, + iconSize: 24, + icon: const Icon( + Icons.notifications_none, + color: AppColors.onCosmic, + ), + // 알림 — 화면 대조 원장 F0 · `229:11265`. + onPressed: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => NotificationScreen(repository: notifications), + ), + ), + tooltip: context.l10n.homeNotificationsTooltip, + ), + ], + ), + ), + ); +} + +class _HomeBody extends StatelessWidget { + const _HomeBody({ + required this.watchPlanet, + required this.galaxy, + required this.zap, + required this.planetRepository, + required this.data, + required this.repository, + required this.onRefresh, + required this.onCreate, + required this.questRepository, + required this.campaignRepository, + required this.eclipse, + required this.notifications, + }); + + /// 성운·행성 생성 화면을 열고, 만들어졌으면 HOME 을 다시 읽는다. + final Future Function(Widget screen) onCreate; + + final NotificationRepository notifications; + + final HomeSnapshot data; + final Future? galaxy; + final ZapController? zap; + final HomeRepository repository; + final PlanetWatch watchPlanet; + final PlanetRepositoryFactory? planetRepository; + final EclipseRepository eclipse; + final Future Function() onRefresh; + + /// F4 로 들고 갈 저장소. 홈은 값만 옮기고 어느 구현인지는 모른다. + final QuestRepository questRepository; + final CampaignRepository? campaignRepository; + + /// 섹션은 ListView 의 직계 자식으로 둔다. 하나의 Column 으로 감싸면 첫 프레임에 + /// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고 + /// 오판한다. 좌우 여백은 자식마다 준다. + static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4); + + /// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16. + static const _sectionGap = 36.0; + static const _headerGap = AppSpace.s4; + + @override + Widget build(BuildContext context) { + final top = MediaQuery.paddingOf(context).top; + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView( + padding: const EdgeInsets.only(bottom: AppSpace.s7), + children: [ + // 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고 + // 헤더 행에 상단 여백을 직접 준다. + Stack( + children: [ + LiveStreamBuilder( + key: ValueKey((repository, data.fandomId)), + create: () => repository.watchGrowth(data.fandomId), + builder: (context, growth) => FutureBuilder( + future: galaxy, + builder: (context, galaxyState) => HomeHero( + zap: zap, + zapData: galaxyState.data, + miniMap: + galaxyState.data == null || + planetRepository == null || + zap == null + ? null + : GalaxyMiniMap( + size: 140, + data: galaxyState.data!, + onOpen: () => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => GalaxyScreen( + repository: zap!.repository, + planetRepository: planetRepository!, + watchPlanet: watchPlanet, + eclipse: eclipse, + ), + ), + ), + ), + // 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면 + // 스냅샷의 `liveTicker` 문자열로 물러난다(폴백). + ticker: _LiveTickerSlot( + eclipse: eclipse, + fandomId: data.fandomId, + fallback: data.liveTicker, + ), + growth: growth ?? data.growth, + fandomId: data.fandomId, + unjoined: data.fandomId.isEmpty, + watchPlanet: watchPlanet, + onOpenPlanet: + planetRepository == null || data.fandomId.isEmpty + ? null + : () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + repository: planetRepository!(data.fandomId), + watchPlanet: watchPlanet, + eclipse: eclipse, + ), + ), + ), + onOpenHistory: planetRepository == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlanetHistoryScreen( + repository: planetRepository!(data.fandomId), + fandomName: + galaxyState.data?.mine?.name ?? + context.l10n.homeFandomHistory, + ), + ), + ), + // 만들고 돌아오면 HOME 이 새 성운/행성(내 Primary)을 다시 그린다. + onBirth: () => onCreate( + PlanetCreateScreen( + nebulaName: (growth ?? data.growth).fandomName, + fandomId: data.fandomId, + repository: repository, + ), + ), + onCreateNebula: () => onCreate( + NebulaCreateScreen( + repository: repository, + myPop: data.popBalance, + ), + ), + // 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」). + onJoin: () => GoRouter.maybeOf(context)?.go('/galaxy'), + onItem: () => showItemSheet( + context, + items: data.items, + purchase: false, + ), + onHeroParticipate: + planetRepository == null || data.fandomId.isEmpty + ? null + : () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => HeroParticipationSheet( + fandomId: data.fandomId, + watch: watchPlanet, + repository: planetRepository!(data.fandomId), + ), + ); + }, + ), + ), + ), + Positioned( + top: top, + left: 0, + right: 0, + child: HomeHeader( + pop: zap?.balance ?? data.popBalance, + notifications: notifications, + items: data.items, + fandomId: data.fandomId, + ), + ), + ], + ), + // 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 + // 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다. + if (data.fandomId.isNotEmpty) + EclipseBanner( + repository: eclipse, + fandomId: data.fandomId, + // 히어로 아래 24 는 히어로 상자(ZAP 걸침 몫)가 이미 준다. 구획 사이는 36(`Frame 4241`). + margin: const EdgeInsets.fromLTRB( + AppSpace.s4, + 0, + AppSpace.s4, + _sectionGap, + ), + ), + Padding( + padding: _h, + child: SectionHeader( + title: 'TODAY’S QUEST', + onMore: () => + _openQuests(context, questRepository, data.popBalance), + ), + ), + const SizedBox(height: _headerGap), + // 사본 카드(196×137)를 가로로 늘어놓는다(`Frame 1984081088` · 간격 8). + SizedBox( + // 글자 확대 시 잘리지 않게 글자 몫(22+6+20+28)만 배율을 따른다. + height: 12 * 2 + 37 + MediaQuery.textScalerOf(context).scale(76), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: _h, + itemCount: data.quests.length, + separatorBuilder: (_, _) => const SizedBox(width: AppSpace.s2), + itemBuilder: (context, i) => SizedBox( + width: QuestCard.width, + child: QuestCard( + quest: data.quests[i], + // 타임어택은 목록 첫 페이지에 든다는 보장이 없어 상세로 바로 간다. + onTap: data.quests[i].endsAt == null + ? () => + _openQuests(context, questRepository, data.popBalance) + : () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => QuestDetailScreen( + questId: data.quests[i].id, + repository: questRepository, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: _sectionGap), + + // 0 건이면 헤더까지 그리지 않는다 — 정책 원장 B-47 「News·SNS 모두 0건 시 + // Briefing 블록 전체 숨김(빈 상태 문구도 노출하지 않음)」. + if (data.briefings.isNotEmpty) ...[ + Padding( + padding: _h, + child: SectionHeader( + title: 'AI Daily Briefing', + // 「홈_브리핑 공유」 — 이 자리가 공유 버튼이다(`266:90529` · 명세 `179:29117`). + moreLabel: context.l10n.commonShare, + onMore: () => BriefingShareDialog.show( + context, + items: data.briefings, + fandomName: data.growth.fandomName, + link: briefingShareLink(data.fandomId, kstDayKey()), + ), + ), + ), + const SizedBox(height: _headerGap), + Padding( + padding: _h, + child: BriefingList(items: data.briefings), + ), + const SizedBox(height: _sectionGap), + ], + + if (data.campaign case final c?) ...[ + Padding( + padding: _h, + child: SectionHeader( + title: 'HOT ARCHIVE', + onMore: () => _openCampaigns(context, campaignRepository), + ), + ), + const SizedBox(height: _headerGap), + Padding( + padding: _h, + child: CampaignCard( + campaign: c, + onTap: () => _openCampaigns(context, campaignRepository), + ), + ), + const SizedBox(height: _sectionGap), + ], + + Padding( + padding: _h, + child: SectionHeader( + title: context.l10n.homePopularInside, + onMore: () => _notYet(context, context.l10n.homeWhereInsideList), + ), + ), + const SizedBox(height: _headerGap), + for (final i in data.insides) ...[ + Padding( + padding: _h, + child: InsideCard( + digest: i, + onTap: () => + _notYet(context, context.l10n.homeWhereInsideDetail), + ), + ), + const SizedBox(height: AppSpace.s3), + ], + ], + ), + ); + } +} + +/// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다. +class _PopBalance extends StatelessWidget { + const _PopBalance({this.value}); + + final int? value; + + @override + Widget build(BuildContext context) => Container( + // 사본 `Frame 1984081119` — 민트 10% 알약 · 1 테두리 · 패딩 4/8 · 간격 5. + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2, vertical: 4), + decoration: BoxDecoration( + color: AppColors.cosmicMint.withValues(alpha: 0.1), + border: Border.all(color: AppColors.cosmicMint), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Image( + image: AssetImage('assets/figma/icon-bolt.png'), + width: 10, + height: 10.4, + ), + const SizedBox(width: 5), + Text( + value == null ? '—' : formatPop(value!), + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: -0.3, + color: AppColors.cosmicMint, + ), + ), + ], + ), + ); +} + +class _LoadFailed extends StatelessWidget { + const _LoadFailed({required this.onRetry}); + + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpace.s5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.homeLoadFailed, style: text.titleMedium), + const SizedBox(height: AppSpace.s2), + Text( + context.l10n.homeRetryLater, + style: text.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpace.s4), + FilledButton( + onPressed: onRetry, + child: Text(context.l10n.commonRetry), + ), + ], + ), + ), + ); + } +} + +/// 아직 라우트가 없는 곳으로 가려 할 때. +/// +/// 조용히 무시하면 검수 때 「눌러도 아무 일이 없다」로 보인다. 무엇이 언제 +/// 열리는지 알려 주는 편이 낫다. +void _notYet(BuildContext context, String where) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.commonNotYet(where)), + behavior: SnackBarBehavior.floating, + ), + ); +} + +/// 홈의 HOT ARCHIVE 에서 캠페인 목록으로 — 화면 대조 원장 F1 → F5. +/// +/// 홈 카드에는 캠페인 ID 가 없다([CampaignHighlight] 는 표시용 요약이다). +/// 상세로 바로 보내려면 ID 를 홈 데이터에 넣어야 하는데, 그러면 홈이 캠페인 +/// 스키마를 알게 된다. 목록을 거쳐 들어간다. +void _openCampaigns(BuildContext context, CampaignRepository? repository) { + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => CampaignListScreen(repository: repository), + ), + ); +} + +/// 홈의 오늘의 퀘스트에서 퀘스트 목록으로 — 화면 대조 원장 F1 → F4. +/// +/// 보유 POP 을 함께 넘긴다. 목록 헤더가 같은 값을 다시 읽으면 왕복이 하나 늘고, +/// 두 화면의 숫자가 한순간 어긋난다. +void _openQuests( + BuildContext context, + QuestRepository repository, + int popBalance, +) { + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + QuestListScreen(repository: repository, myPop: popBalance), + ), ); } diff --git a/app/lib/features/home/nebula_create_screen.dart b/app/lib/features/home/nebula_create_screen.dart new file mode 100644 index 0000000..60ae64e --- /dev/null +++ b/app/lib/features/home/nebula_create_screen.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; +import '../../core/data/backend.dart' show BackendException; +import 'home_data.dart'; +import 'home_screen.dart' show formatPop; +import '../../core/i18n/app_locale.dart'; + +/// 성운 생성 — Figma 사본 「홈/성운 생성」(`340:75466`) · 이름 입력 후(`340:75644`). +/// +/// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다. +/// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식 +/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정 +/// 표시를 둔다(사본 슬라이더 ↔ 원장 고정값, 해석 확인 문항). B-19 — 진화 참여자에게는 +/// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다. +/// +/// 사본이 함께 그린 다이얼로그 두 개를 그대로 쓴다. +/// - 「선택한 행성을 기본 행성으로 변경할까요?」 취소 / 변경 +/// - 「이미 오늘 성운을 생성하셨습니다.」 확인 ← **일 1회 제한** +class NebulaCreateScreen extends StatefulWidget { + const NebulaCreateScreen({ + super.key, + this.alreadyCreatedToday = false, + this.myPop = 0, + this.repository = const SampleHomeRepository(), + }); + + /// 생성 경로(`fn-nebula-create`). 성공하면 만든 팬덤 ID 를 들고 이 화면을 닫는다. + final HomeRepository repository; + + /// 오늘 이미 만들었는가. Figma 의 「생성 횟수 초과」 상태. + final bool alreadyCreatedToday; + + /// 「나의 보유 POP」 — 여는 쪽이 지갑의 활동 POP 을 넘긴다. + final int myPop; + + @override + State createState() => _NebulaCreateScreenState(); +} + +class _NebulaCreateScreenState extends State { + final _name = TextEditingController(); + + String get _value => _name.text.trim(); + + /// 서버와 같은 `config/policy` 운영값. 읽기 전 · 실패 시 원장 기본값. + NebulaTargets _targets = defaultNebulaTargets; + /// 서버가 이미 쓰인다고 답한 이름(E-FDM-01). 중복 판정은 서버만 한다 — + /// `fandomNames` 는 클라이언트가 읽지 못한다. + String? _takenName; + bool get _taken => _takenName != null && _takenName == _value; + + @override + void initState() { + super.initState(); + _name.addListener(() => setState(() {})); + widget.repository.nebulaTargets().then((t) { + if (mounted) setState(() => _targets = t); + }, onError: (_) {}); + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + static const _muted = Color(0xFFCDCDCD); + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + bottom: false, + child: Column( + children: [ + // 사본 `Top Navigation` — 왼쪽 X 만. + SizedBox( + height: 56, + child: Align( + alignment: Alignment.centerLeft, + child: IconButton( + constraints: const BoxConstraints.tightFor( + width: 56, + height: 56, + ), + icon: const ImageIcon( + AppIcons.x, + size: 24, + color: AppColors.onCosmic, + ), + tooltip: context.l10n.commonClose, + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(AppSpace.s4), + children: [ + Text( + context.l10n.nebulaHeadline, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 20, + fontWeight: FontWeight.w400, + height: 28 / 20, + letterSpacing: -0.5, + color: AppColors.onCosmic, + ), + ), + const SizedBox(height: 20), + // 사본 문단 상자는 84(4줄) — 줄 수가 달라도 아래 배치가 흔들리지 않게 높이를 잡는다. + SizedBox( + height: 84, + child: Text( + context.l10n.nebulaBody('${_targets.days}'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1.4, + letterSpacing: -0.5, + color: _muted, + ), + ), + ), + const SizedBox(height: AppSpace.s5), + _NameInput(controller: _name, error: _taken), + const SizedBox(height: 6), + SizedBox( + height: 17, + child: _taken + ? Text( + context.l10n.nebulaNameTaken, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 12, + height: 1.4, + color: Color(0xFFEC6D6D), + ), + ) + : null, + ), + const SizedBox(height: AppSpace.s5), + Center( + child: Image.asset( + 'assets/figma/nebula-home.png', + width: 212, + height: 212, + ), + ), + const SizedBox(height: AppSpace.s5), + _Target(myPop: widget.myPop, targets: _targets), + ], + ), + ), + // 이름을 넣은 뒤(`340:75644`)에만 「성운 생성」이 나온다. + if (_value.isNotEmpty) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpace.s4, + 0, + AppSpace.s4, + 0, + ), + child: FilledButton( + onPressed: _taken ? null : _confirm, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + textStyle: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + child: Text(context.l10n.nebulaCreate), + ), + ), + ) + else + SizedBox(height: MediaQuery.paddingOf(context).bottom), + ], + ), + ), + ); + + Future _confirm() async { + // 일 1회 제한이 걸려 있으면 확인 다이얼로그를 띄우지 않고 사유를 알린다. + if (widget.alreadyCreatedToday) { + await showAlertDialog(context, context.l10n.nebulaAlreadyToday); + return; + } + final ok = await showConfirmDialog( + context, + context.l10n.galaxySetPrimaryConfirm, + ); + if (!ok || !mounted || _busy) return; + _busy = true; + final name = _value; + try { + final created = await widget.repository.createNebula(name); + if (!mounted) return; + // C-15 — 대표 변경 30일 대기 중이면 성운만 생기고 대표는 그대로다. 알리고 닫는다. + if (!created.primaryChanged) { + await showAlertDialog( + context, + context.l10n.nebulaCreatedKeepPrimary, + ); + if (!mounted) return; + } + Navigator.of(context).pop(created.fandomId); + } on BackendException catch (e) { + if (!mounted) return; + // 이름 중복은 사본의 입력칸 오류 상태로, 나머지(일 1회 E-FDM-02 · 이름 규칙)는 알림으로. + if (e.code == 'E-FDM-01') { + setState(() => _takenName = name); + } else { + await showAlertDialog(context, e.message); + } + } catch (_) { + if (mounted) await showAlertDialog( + context, + context.l10n.nebulaCreateFailed, + ); + } finally { + _busy = false; + } + } + + /// 생성 요청 중 — 연타로 두 번 부르지 않는다. + bool _busy = false; +} + +/// 사본 `Input` — 271×58 · 분홍 10% · r8 · 패딩 12/16 · 42dot Medium 18 가운데. +/// 이름이 들어가면 분홍 1 테두리(`340:75650`). +class _NameInput extends StatelessWidget { + const _NameInput({required this.controller, required this.error}); + + final TextEditingController controller; + final bool error; + + @override + Widget build(BuildContext context) { + OutlineInputBorder border(Color c) => OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: BorderSide(color: c), + ); + final filled = controller.text.trim().isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: TextField( + controller: controller, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 18, + fontWeight: FontWeight.w500, + height: 1, + letterSpacing: -0.45, + color: AppColors.onCosmic, + ), + decoration: InputDecoration( + hintText: context.l10n.nebulaNameHint, + hintStyle: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 18, + fontWeight: FontWeight.w500, + color: AppColors.onCosmicMuted, + ), + filled: true, + fillColor: AppColors.cosmicAccent.withValues(alpha: 0.1), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpace.s4, + vertical: 20, + ), + enabledBorder: border( + filled ? AppColors.cosmicAccent : Colors.transparent, + ), + focusedBorder: border(AppColors.cosmicAccent), + ), + ), + ); + } +} + +/// 목표 영역 — 사본 `Frame 1984081264`. 슬라이더 자리는 원장 고정값을 보여 주기만 한다. +class _Target extends StatelessWidget { + const _Target({required this.myPop, required this.targets}); + + final int myPop; + final NebulaTargets targets; + + static const _scale = TextStyle( + fontFamily: AppFonts.display, + fontSize: 12, + height: 1, + color: Color(0xFFCDCDCD), + ); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + // 사본은 ABeeZee Italic 이지만 한글은 기울지 않는다 — 라틴 글자만 기울인다. + Expanded( + child: Text.rich( + TextSpan( + text: context.l10n.nebulaGoal, + children: [ + TextSpan( + text: 'POP', + style: TextStyle(fontStyle: FontStyle.italic), + ), + TextSpan(text: context.l10n.nebulaFounders), + ], + ), + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 18, + fontWeight: FontWeight.w400, + height: 28 / 18, + letterSpacing: -0.5, + color: AppColors.onCosmic, + ), + ), + ), + Text( + '${formatPop(targets.pop)}P / ' + '${targets.members}', + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 18, + fontWeight: FontWeight.w500, + height: 1, + letterSpacing: -0.45, + color: AppColors.cosmicAccent, + ), + ), + ], + ), + ), + const SizedBox(height: AppSpace.s3), + // 고정 목표 — 트랙 전체를 채우고 손잡이는 두지 않는다(끌 수 없다는 표시). + Semantics( + label: + context.l10n.nebulaGoalSemantics( + formatPop(targets.pop), + '${targets.members}', + '${targets.days}', + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Container( + height: 6, + decoration: BoxDecoration( + color: AppColors.cosmicAccent, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + ), + const SizedBox(height: 6), + Row( + children: [ + const Text('0', style: _scale), + const Spacer(), + Text(formatPop(targets.pop), style: _scale), + ], + ), + const SizedBox(height: AppSpace.s3), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text.rich( + TextSpan( + text: context.l10n.commonMyPop, + children: [ + TextSpan( + text: formatPop(myPop), + style: const TextStyle(color: AppColors.onCosmic), + ), + ], + ), + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1, + letterSpacing: -0.35, + color: AppColors.onCosmicVariant, + ), + ), + ), + ], + ); +} + +/// 취소 / 변경 두 갈래. +Future showConfirmDialog(BuildContext context, String message) async { + final r = await showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.commonChange), + ), + ], + ), + ); + return r ?? false; +} + +/// 확인 하나뿐인 알림. +Future showAlertDialog(BuildContext context, String message) => showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.commonConfirm), + ), + ], + ), +); diff --git a/app/lib/features/home/planet_create_screen.dart b/app/lib/features/home/planet_create_screen.dart new file mode 100644 index 0000000..fdadff3 --- /dev/null +++ b/app/lib/features/home/planet_create_screen.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; + +import '../../core/data/backend.dart' show BackendException; +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; +import '../../core/ui/planet_25d.dart' show planetColors; +import 'home_data.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 행성 생성 — Figma 사본 「홈_성장 완료/행성 생성」(`344:214421`) → 「행성 색 선택」 +/// (`2066:17600`) → 생성 확인(`373:358639`). +/// +/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고, +/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과 +/// 직접 색 고르기(스포이트) 칩은 두지 않는다. +/// +/// 형태 칸은 사본에서도 모두 같은 자리표시 이미지다(개별 형태 에셋은 S08 확인 대상). +class PlanetCreateScreen extends StatefulWidget { + const PlanetCreateScreen({ + super.key, + this.nebulaName = 'BTS', + this.fandomId = '', + this.repository = const SampleHomeRepository(), + }); + + final String nebulaName; + + /// 탄생시킬 성운(팬덤) ID. + final String fandomId; + + /// 생성 경로(`fn-planet-create`). 성공하면 팬덤 ID 를 들고 이 화면을 닫는다. + final HomeRepository repository; + + /// 형태 키. 사본 칸에 이름이 없어 순번으로 둔다(에셋 확정 때 이름을 붙인다). + static const shapes = [ + 'shape1', + 'shape2', + 'shape3', + 'shape4', + 'shape5', + 'shape6', + ]; + + /// 색 키와 칩 색 — 렌더링과 같은 표([planetColors]). + static const colors = planetColors; + + @override + State createState() => _PlanetCreateScreenState(); +} + +class _PlanetCreateScreenState extends State { + int _shape = 0; + int _color = 0; + bool _colorStep = false; + + static const _placeholder = AssetImage( + 'assets/figma/planet-shape-placeholder.png', + ); + + static const _heading = TextStyle( + fontFamily: AppFonts.body, + fontSize: 18, + fontWeight: FontWeight.w600, + height: 24 / 18, + color: AppColors.onCosmic, + ); + + Widget _preview() => Center( + child: SizedBox.square( + dimension: 212, + child: _colorStep + ? ColorFiltered( + colorFilter: ColorFilter.mode( + PlanetCreateScreen.colors[_color].$2, + BlendMode.modulate, + ), + child: const Image(image: _placeholder), + ) + : const Image(image: _placeholder), + ), + ); + + /// 사본 `Frame 1984081334` — #2C2C2C r12 칸, 고른 칸은 분홍 2 테두리. + Widget _tile(int i, double size) => GestureDetector( + onTap: () => setState(() => _shape = i), + child: Semantics( + button: true, + selected: _shape == i, + label: context.l10n.planetShape('${i + 1}'), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFF2C2C2C), + borderRadius: BorderRadius.circular(AppRadius.md), + border: _shape == i + ? 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', + ), + ), + ), + ); + + Widget _shapes() => LayoutBuilder( + builder: (context, box) { + final third = (box.maxWidth - 16) / 3; + final half = (box.maxWidth - 8) / 2; + // 사본 배치 — 3칸 · 2칸 · 1칸(아래는 버튼 뒤로 스크롤). + return Column( + children: [ + Row( + children: [ + for (final i in [0, 1, 2]) ...[ + if (i > 0) const SizedBox(width: 8), + _tile(i, third), + ], + ], + ), + const SizedBox(height: AppSpace.s5), + Row( + children: [ + _tile(3, half), + const SizedBox(width: 8), + _tile(4, half), + ], + ), + const SizedBox(height: AppSpace.s5), + _tile(5, box.maxWidth), + ], + ); + }, + ); + + /// 사본 `Frame 1984081340` — 28 원 · 간격 8 · 고른 칩 분홍 4 테두리, 나머지 흰 2. + Widget _colors() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.planetBgColor, style: _heading), + const SizedBox(height: AppSpace.s4), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < PlanetCreateScreen.colors.length; i++) + GestureDetector( + onTap: () => setState(() => _color = i), + child: Semantics( + button: true, + selected: _color == i, + label: PlanetCreateScreen.colors[i].$1, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: PlanetCreateScreen.colors[i].$2, + border: Border.all( + color: _color == i + ? AppColors.cosmicAccent + : AppColors.onCosmic, + width: _color == i ? 4 : 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + ), + ), + ), + ], + ), + ], + ); + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + bottom: false, + child: Column( + children: [ + _TopBar(title: _colorStep + ? context.l10n.planetColorSelect + : context.l10n.planetTypeSelect), + Expanded( + child: ListView( + // 사본 주 프레임(`344:214421`)만 본문이 21 아래서 시작한다(y125). + padding: EdgeInsets.fromLTRB( + AppSpace.s4, + AppSpace.s4 + (_colorStep ? 0 : 21), + AppSpace.s4, + AppSpace.s4, + ), + children: [ + _preview(), + const SizedBox(height: AppSpace.s5), + if (_colorStep) _colors() else _shapes(), + ], + ), + ), + ColoredBox( + color: AppColors.cosmicVoid, + child: SafeArea( + top: false, + child: SizedBox( + height: 56, + child: _colorStep + // 사본 `2066:17600` — 반폭 두 버튼(이전 검정 · 완료 분홍), 간격 10. + ? Row( + children: [ + Expanded( + child: _Button( + label: context.l10n.commonPrev, + fill: Colors.black, + ink: AppColors.onCosmic, + onTap: () => setState(() => _colorStep = false), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _Button(label: context.l10n.commonDone, onTap: _confirm), + ), + ], + ) + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s4, + ), + child: _Button( + label: context.l10n.commonNext, + onTap: () => setState(() => _colorStep = true), + ), + ), + ), + ), + ), + ], + ), + ), + ); + + Future _confirm() async { + final ok = await showDialog( + context: context, + barrierColor: const Color(0xB3000000), + builder: (context) => const _ConfirmDialog(), + ); + if (ok != true || !mounted || _busy) return; + _busy = true; + try { + await widget.repository.createPlanet( + fandomId: widget.fandomId, + planetType: PlanetCreateScreen.shapes[_shape], + colorKey: PlanetCreateScreen.colors[_color].$1, + ); + if (mounted) Navigator.of(context).pop(widget.fandomId); + } on BackendException catch (e) { + _fail(e.message); + } catch (_) { + _fail(context.l10n.planetCreateFailed); + } finally { + _busy = false; + } + } + + /// 생성 요청 중 — 연타로 두 번 부르지 않는다. + bool _busy = false; + + void _fail(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } +} + +/// 사본 `Top Navigation` — 왼쪽 X(24) · 가운데 제목(16 SemiBold) · 오른쪽 빈 자리(「완료」 투명도 0). +class _TopBar extends StatelessWidget { + const _TopBar({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) => SizedBox( + height: 56, + child: Row( + children: [ + IconButton( + constraints: const BoxConstraints.tightFor(width: 56, height: 56), + icon: const ImageIcon( + AppIcons.x, + size: 24, + color: AppColors.onCosmic, + ), + tooltip: context.l10n.commonClose, + onPressed: () => Navigator.of(context).maybePop(), + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.5, + color: AppColors.onCosmic, + ), + ), + ), + // 사본의 오른쪽 「완료」는 투명도 0 이라 보이지 않는다. 제목 가운데 정렬용 자리만 둔다. + const SizedBox(width: 56), + ], + ), + ); +} + +/// 사본 `Button` — 56 높이 · r8 · 16 SemiBold. +class _Button extends StatelessWidget { + const _Button({ + required this.label, + required this.onTap, + this.fill = AppColors.cosmicAccent, + this.ink = AppColors.onCosmicAccent, + }); + + final String label; + final VoidCallback onTap; + final Color fill; + final Color ink; + + @override + Widget build(BuildContext context) => FilledButton( + onPressed: onTap, + style: FilledButton.styleFrom( + backgroundColor: fill, + foregroundColor: ink, + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + textStyle: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + child: Text(label), + ); +} + +/// 사본 `Frame 1984080943` — 312 폭 #282828 r12 · 패딩 24/16 · 취소(흰 1 테두리) / 생성(분홍). +class _ConfirmDialog extends StatelessWidget { + const _ConfirmDialog(); + + @override + Widget build(BuildContext context) => Dialog( + backgroundColor: const Color(0xFF282828), + insetPadding: const EdgeInsets.symmetric(horizontal: 31), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.planetCreateConfirm, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.5, + color: Color(0xFFF8F8F8), + ), + ), + const SizedBox(height: AppSpace.s4), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context, false), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.onCosmic, + side: const BorderSide(color: AppColors.onCosmic), + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: Text(context.l10n.commonCancel), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: Text(context.l10n.commonCreate), + ), + ), + ], + ), + ], + ), + ), + ); +} diff --git a/app/lib/features/home/widgets/briefing_list.dart b/app/lib/features/home/widgets/briefing_list.dart new file mode 100644 index 0000000..5203c76 --- /dev/null +++ b/app/lib/features/home/widgets/briefing_list.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../core/theme/tokens.dart'; +import '../home_data.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// AI Daily Briefing — Figma 홈의 번호 매긴 헤드라인 줄. +/// +/// **구획을 News 와 SNS 로 나눈다** — 정책 원장 B-47 *News/SNS 각각 0 건이면 해당* +/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만 +/// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다. +/// +/// **한 줄은 제목만 띄운다.** 출처명·게시시각·AI 요약을 어디에 배치할지는 발주사가 +/// HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후 정한다. +/// 데이터는 [BriefingItem] 에 5 요소가 전부 들어와 있으므로 배치만 남는다. +/// +/// 줄을 누르면 원문보기로 나간다(B-75 5 요소 중 「원문보기」). +class BriefingList extends StatelessWidget { + const BriefingList({super.key, required this.items, this.onOpen}); + + final List items; + + /// 원문보기 동작. 기본값은 기본 브라우저로 넘기는 것이고, 테스트가 대신 받는다 — + /// 위젯 테스트에서 실제 브라우저를 띄울 수는 없다. + final Future Function(BriefingItem item)? onOpen; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + // 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다. + final sections = [ + for (final section in BriefingSection.values) + ( + section: section, + rows: items.where((i) => i.section == section).toList() + ..sort((a, b) => a.rank.compareTo(b.rank)), + ), + ].where((g) => g.rows.isNotEmpty).toList(); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s4, + vertical: AppSpace.s2, + ), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (groupIndex, group) in sections.indexed) ...[ + if (groupIndex > 0) const Divider(height: 1), + Padding( + padding: const EdgeInsets.only( + top: AppSpace.s3, + bottom: AppSpace.s1, + ), + child: Text( + group.section.label, + style: t.labelSmall?.copyWith( + color: AppColors.cosmicAccent, + fontWeight: FontWeight.w600, + ), + ), + ), + for (final (i, item) in group.rows.indexed) ...[ + if (i > 0) const Divider(height: 1), + _Row(item: item, onOpen: () => _open(context, item)), + ], + ], + ], + ), + ); + } + + Future _open(BuildContext context, BriefingItem item) async { + final handler = onOpen; + if (handler != null) { + await handler(item); + return; + } + final opened = await launchUrl( + Uri.parse(item.articleUrl), + mode: LaunchMode.externalApplication, + ); + // 열리지 않으면 알린다. 조용히 아무 일도 없으면 눌린 것인지조차 알 수 없다. + if (!opened && context.mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(context.l10n.briefingOpenFailed), + behavior: SnackBarBehavior.floating, + )); + } + } +} + +class _Row extends StatelessWidget { + const _Row({required this.item, required this.onOpen}); + + final BriefingItem item; + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpace.s3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + child: Text( + '${item.rank}.', + style: t.bodySmall?.copyWith( + color: AppColors.cosmicAccent, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded(child: Text(item.headline, style: t.bodySmall)), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/home/widgets/briefing_share.dart b/app/lib/features/home/widgets/briefing_share.dart new file mode 100644 index 0000000..cc2df62 --- /dev/null +++ b/app/lib/features/home/widgets/briefing_share.dart @@ -0,0 +1,285 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../core/i18n/app_locale.dart'; +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/app_icons.dart'; +import '../../campaign/campaign_share.dart' show shareHashtags, shareLink, shareBaseUrl; +import '../home_data.dart'; + +/// 브리핑 공유 — 「홈_브리핑 공유」 `266:90445` · 인스타 `272:240423` · 틱톡 `272:240430`. +/// +/// 별첨 1 ③-5 「카드 열람 → SNS 공유 카드 정상 노출」. 명세 `523:18300` — AI 브리핑 +/// 최대 3건 · `523:18306` 인스타그램 · 틱톡 · X · 링크 복사. +/// +/// SNS 셋은 **카드 이미지 + 문구 + 링크**를 OS 공유 시트로 보낸다(인스타 · 틱톡은 이미지가 +/// 있어야 올라간다). 링크 복사는 문구와 링크를 클립보드에 둔다. + +/// 그날 팬덤 브리핑으로 돌아오는 링크. 식별자는 팬덤과 KST 날짜다(상세정의 v1.6.1 「공유 +/// 링크에는 원래 문맥을 복원할 수 있는 식별자를 포함」 · B-47 브리핑은 팬덤 · 당일 단위). +String? briefingShareLink(String fandomId, String dayKey, {String base = shareBaseUrl}) => + shareLink(['b', fandomId, dayKey], base: base); + +/// 공유 문구 — 제목 한 줄 · 헤드라인(최대 3) · 링크 · 해시태그(B-78 동적 구성). +String briefingShareText({ + required List items, + String? fandomName, + String? link, +}) => [ + 'AI DAILY BRIEFING', + for (final (i, item) in items.take(3).indexed) '${i + 1}. ${item.headline}', + ?link, + shareHashtags(fandomName: fandomName, topic: 'AIBriefing').join(' '), +].join('\n'); + +/// 공유 오버레이. 캠페인 공유(`480:14594`)와 같은 틀이다. +class BriefingShareDialog extends StatefulWidget { + const BriefingShareDialog({ + super.key, + required this.items, + required this.fandomName, + this.link, + this.share, + }); + + final List items; + final String fandomName; + final String? link; + + /// 공유 시트 호출. 테스트가 대신 받는다 — 위젯 테스트에서 OS 공유 시트를 띄울 수 없다. + final Future Function(ShareParams params)? share; + + static Future show( + BuildContext context, { + required List items, + required String fandomName, + String? link, + Future Function(ShareParams params)? share, + }) => showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: context.l10n.commonClose, + barrierColor: AppColors.cosmicVoid, + pageBuilder: (_, _, _) => BriefingShareDialog( + items: items, + fandomName: fandomName, + link: link, + share: share, + ), + ); + + @override + State createState() => _BriefingShareDialogState(); +} + +class _BriefingShareDialogState extends State { + final _card = GlobalKey(); + + static const _icons = [ + 'assets/figma/share-instagram.png', + 'assets/figma/share-tiktok.png', + 'assets/figma/share-x.png', + 'assets/figma/share-link.png', + ]; + + String get _text => briefingShareText( + items: widget.items, + fandomName: widget.fandomName, + link: widget.link, + ); + + /// 카드 영역을 PNG 로 굽는다. 실패하면 null — 문구만이라도 보낸다. + Future _capture() async { + final boundary = + _card.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return null; + final image = await boundary.toImage(pixelRatio: 3); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (bytes == null) return null; + return XFile.fromData( + bytes.buffer.asUint8List(), + mimeType: 'image/png', + name: 'fanuzu-briefing.png', + ); + } + + /// 공유 중. 연타로 공유 시트가 겹쳐 뜨지 않게 한다. + bool _busy = false; + + /// [button] 은 누른 아이콘의 문맥 — iPad 공유 팝오버가 그 자리에서 뜬다. + Future _onTap(int index, BuildContext button) async { + if (_busy) return; + final box = button.findRenderObject() as RenderBox?; + final origin = box == null + ? null + : box.localToGlobal(Offset.zero) & box.size; + final l10n = context.l10n; + final messenger = ScaffoldMessenger.maybeOf(context); + void toast(String message) => messenger + ?..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + setState(() => _busy = true); + try { + if (index == _icons.length - 1) { + await Clipboard.setData(ClipboardData(text: _text)); + if (!mounted) return; + // 오버레이를 닫고 알린다 — 열린 채로 띄우면 스낵바가 장막 뒤에 가린다. + Navigator.of(context).pop(); + toast(widget.link == null + ? l10n.briefingShareTextCopied + : l10n.briefingShareCopied); + return; + } + final file = await _capture(); + await (widget.share ?? SharePlus.instance.share)(ShareParams( + text: _text, + subject: 'AI DAILY BRIEFING', + files: file == null ? null : [file], + sharePositionOrigin: origin, + )); + } catch (_) { + toast(l10n.commonRequestFailed); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final labels = [ + context.l10n.shareInstagram, + context.l10n.shareTiktok, + 'X', + // 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」다. + widget.link == null ? context.l10n.shareCopyText : context.l10n.shareCopyLink, + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 23, 20, 22), + child: Center( + child: Material( + color: const Color(0xFF101010), + borderRadius: BorderRadius.circular(24), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: context.l10n.commonClose, + onPressed: () => Navigator.of(context).maybePop(), + icon: const ImageIcon( + AppIcons.x, + size: 24, + color: AppColors.onCosmic, + ), + ), + ), + // 공유 이미지로 굽는 영역 — 제목 · 행성 · 헤드라인. + RepaintBoundary( + key: _card, + child: ColoredBox( + color: const Color(0xFF101010), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'AI DAILY BRIEFING', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 25, + fontWeight: FontWeight.w600, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + const SizedBox(height: 24), + Center( + child: Image.asset( + 'assets/figma/planet-home.png', + width: 120, + height: 120, + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(AppSpace.s4), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + children: [ + for (final (i, item) + in widget.items.take(3).indexed) + Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpace.s2, + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox( + width: 32, + child: Text('${i + 1}', style: _row), + ), + Expanded( + child: Text(item.headline, style: _row), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 36), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final (i, asset) in _icons.indexed) ...[ + if (i > 0) const SizedBox(width: 16), + Tooltip( + message: labels[i], + child: Builder( + builder: (button) => InkResponse( + onTap: () => _onTap(i, button), + radius: 26, + child: Image.asset(asset, width: 36, height: 36), + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ), + ); + } + + static const _row = TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + color: AppColors.onCosmicVariant, + ); +} diff --git a/app/lib/features/home/widgets/campaign_card.dart b/app/lib/features/home/widgets/campaign_card.dart new file mode 100644 index 0000000..246899c --- /dev/null +++ b/app/lib/features/home/widgets/campaign_card.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../home_data.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// HOT ARCHIVE — 진행 중인 캠페인 한 건. +/// +/// Figma 「다이브짱 / 92% 달성 / 강남역 초대형 LED 광고 / 48 · 24 · 82」. +/// 정책 설계서 §P-09 — 실패 시 100% 자동 환불이 이 기능의 검수 기준이다. +class CampaignCard extends StatelessWidget { + const CampaignCard({super.key, required this.campaign, this.onTap}); + + final CampaignHighlight campaign; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Material( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 시안 HOT ARCHIVE 카드(252:159447) — 위에 이미지 342×228. + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: AspectRatio( + aspectRatio: 342 / 228, + child: Image.asset('assets/figma/campaign-sample.png', fit: BoxFit.cover), + ), + ), + const SizedBox(height: AppSpace.s3), + Row( + children: [ + Text(campaign.authorName, style: t.bodySmall), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s2, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.cosmicAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + context.l10n.campaignAchieved('${campaign.achievedPercent}'), + style: t.labelSmall?.copyWith( + color: AppColors.cosmicAccent, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpace.s3), + Text(campaign.title, style: t.titleMedium), + const SizedBox(height: AppSpace.s2), + Text( + campaign.description, + style: t.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpace.s3), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: (campaign.achievedPercent / 100).clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: AppColors.cosmicSurfaceRaised, + valueColor: const AlwaysStoppedAnimation( + AppColors.cosmicAccent, + ), + ), + ), + const SizedBox(height: AppSpace.s3), + Row( + children: [ + _Stat(icon: Icons.people_outline, value: campaign.supporters), + const SizedBox(width: AppSpace.s4), + _Stat( + icon: Icons.chat_bubble_outline, + value: campaign.comments, + ), + const SizedBox(width: AppSpace.s4), + _Stat(icon: Icons.favorite_border, value: campaign.likes), + const Spacer(), + Text( + context.l10n.campaignView, + style: t.bodySmall?.copyWith(color: AppColors.cosmicAccent), + ), + const Icon( + Icons.chevron_right, + size: 16, + color: AppColors.cosmicAccent, + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat({required this.icon, required this.value}); + + final IconData icon; + final int value; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppColors.onCosmicVariant), + const SizedBox(width: AppSpace.s1), + Text('$value', style: Theme.of(context).textTheme.labelSmall), + ], + ); +} diff --git a/app/lib/features/home/widgets/growth_conditions.dart b/app/lib/features/home/widgets/growth_conditions.dart new file mode 100644 index 0000000..3b7c482 --- /dev/null +++ b/app/lib/features/home/widgets/growth_conditions.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; + +/// 서버가 제공한 두 성장 조건을 각각 표시한다. 미조회는 0으로 보이지 않는다. +class GrowthConditions extends StatelessWidget { + const GrowthConditions({super.key, required this.growth, this.style}); + final PlanetGrowth growth; + + /// 없으면 bodySmall. HOME 성장 카드는 사본의 14 흰색을 준다. + final TextStyle? style; + + String _value(num? value) => value == null + ? '—' + : value == value.roundToDouble() + ? formatPop(value.toInt()) + : value.toString(); + + @override + Widget build(BuildContext context) => growth.stage == 6 + ? Text( + context.l10n.growthFinalReached, + style: style ?? Theme.of(context).textTheme.bodySmall, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final row in [ + (growth.popConditionLabel, growth.currentPop, growth.requiredPop), + // 성운은 POP + 창립 멤버(B-10), 정식 행성은 POP + sFPI(B-11) 동시 충족이다. + growth.isNebula + ? ( + growth.memberConditionLabel, + growth.members, + growth.requiredMembers, + ) + : ( + growth.sfpiConditionLabel, + growth.currentSfpi, + growth.requiredSfpi, + ), + ]) + Text( + '${row.$1} · ${_value(row.$2)} / ${_value(row.$3)}', + style: style ?? Theme.of(context).textTheme.bodySmall, + ), + if (growth.isNebula && growth.nebulaGoalPassed) + Text( + context.l10n.growthNebulaGoalPassed, + style: style ?? Theme.of(context).textTheme.bodySmall, + ), + ], + ); +} diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart new file mode 100644 index 0000000..57d0ee7 --- /dev/null +++ b/app/lib/features/home/widgets/home_hero.dart @@ -0,0 +1,813 @@ +import '../../../core/ui/planet_fx_scope.dart'; +import '../../galaxy/galaxy_data.dart'; +import '../../galaxy/zap_controller.dart'; +import '../../galaxy/widgets/zap_button.dart'; +import '../../galaxy/widgets/zap_feedback.dart'; +import '../../../core/ui/live_stream.dart'; +import '../../../core/ui/hero_time_status.dart'; +import 'growth_conditions.dart'; + +import 'dart:math' as math; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/app_icons.dart'; +import '../../../core/ui/live_planet.dart'; +import '../../../core/data/planet_visual_state.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; + +/// 홈 상단 히어로 — Figma 사본 `252:159354` 홈의 `Frame 1984081117`. +/// +/// 배치는 사본의 기본 상태 변형(`343:205364` 등 ZAP 이 닫힌 홈)을 따른다. 배경은 상태 +/// 표시줄 포함 680 높이, 그 아래(124~680)에 「아바타·문구 → 48 → 행성 212 → 48 → 성장 +/// 카드」를 세로 가운데로 쌓는다(`Frame 1984081116` · gap 48 · CENTER). 원형 ZAP 은 카드 +/// 아래 변에 중심을 걸치므로 히어로 상자를 배경보다 24 길게 잡아 누를 자리를 넣는다. +/// +/// 문구 규칙(Figma 명세) — *「{닉네임} 님의」 작게 · 「{POP} POP 기여로 / 꾸준하게 +/// 성장하고 있어요.」 크게 두 줄*. 텍스트는 왼쪽 정렬, 행성은 가운데 정렬이다. +class HomeHero extends StatefulWidget { + const HomeHero({ + super.key, + required this.growth, + this.fandomId = '', + this.watchPlanet = noPlanetWatch, + this.onHeroParticipate, + this.onOpenHistory, + this.onOpenPlanet, + this.zap, + this.zapData, + this.ticker, + this.onBirth, + this.onCreateNebula, + this.onJoin, + this.unjoined = false, + this.onItem, + this.miniMap, + }); + + final VoidCallback? onOpenHistory; + + /// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다. + final VoidCallback? onOpenPlanet; + final String fandomId; + final PlanetWatch watchPlanet; + final VoidCallback? onHeroParticipate; + + final PlanetGrowth growth; + final ZapController? zap; + final GalaxySnapshot? zapData; + + /// 헤더 바로 아래(y102 · 높이 22) 라이브 띠. 없으면 자리도 비운다. + final Widget? ticker; + + /// 성운 상태에서 성운을 누를 때 — 행성 생성(F1-04)으로 간다. 전환 조건(B-10)을 둘 다 + /// 채웠을 때만 불린다. + final VoidCallback? onBirth; + + /// 소속이 없는 첫 사용자 홈(F1-05)의 「+」 시트 — 행성 생성(성운) · 행성 가입. + final VoidCallback? onCreateNebula; + final VoidCallback? onJoin; + + /// 소속 팬덤이 없는 첫 사용자 상태(`343:212939`) — [HomeSnapshot.fandomId] 가 빈 경우. + final bool unjoined; + + /// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`). + final VoidCallback? onItem; + + /// GALAXY 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시). + /// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다. + final Widget? miniMap; + + /// 사본 기준 배경 높이(상태 표시줄 48 포함). + static const double designHeight = 680; + + /// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다. + static const double zapOverhang = 24; + + @override + State createState() => _HomeHeroState(); +} + +class _HomeHeroState extends State { + final _fxKey = GlobalKey(); + final _planetKey = GlobalKey(); + final _buttonKey = GlobalKey(); + PlanetGrowth get growth => widget.growth; + String get fandomId => widget.fandomId; + PlanetWatch get watchPlanet => widget.watchPlanet; + VoidCallback? get onHeroParticipate => widget.onHeroParticipate; + + Offset _center(GlobalKey key) { + final box = key.currentContext?.findRenderObject(); + final root = _fxKey.currentContext?.findRenderObject(); + return box is RenderBox && box.hasSize && root is RenderBox + ? box.localToGlobal(box.size.center(Offset.zero), ancestor: root) + : Offset.zero; + } + + @override + Widget build(BuildContext context) { + // 사본 좌표는 상태 표시줄 48 을 포함한다. 기기 상단 여백으로 바꿔 쓴다. + final top = MediaQuery.paddingOf(context).top - 48; + final background = top + HomeHero.designHeight; + const planet = 212.0; + final hasZap = + widget.zap != null && + widget.zapData?.joined == true && + widget.zapData?.mine?.id == fandomId; + + return PlanetFxScope( + child: SizedBox( + height: background + HomeHero.zapOverhang, + child: Stack( + key: _fxKey, + children: [ + // 1. 우주 배경 — 시안 `252:159356`. 아래로 갈수록 화면 배경색으로 잠긴다. + Positioned( + top: 0, + left: 0, + right: 0, + height: background, + child: LivePlanetLighting( + fandomId: fandomId, + watch: watchPlanet, + child: Image.asset( + 'assets/figma/bg-space-home.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), + ), + ), + + // 2. 문구 · 행성 · 성장 카드 — `Frame 1984081116`(y124~680, gap 48). + // 사본은 가운데 정렬이지만 카드가 사본(151)보다 한 줄 길어 가운데로 두면 + // 문구가 위로 밀린다. 사본 좌표(문구 y130 · 행성 y263)에 고정해 위에서 쌓는다. + Positioned( + top: top + 124, + left: AppSpace.s4, + right: AppSpace.s4, + height: HomeHero.designHeight - 124, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 첫 사용자 홈(`343:212939`)은 아바타가 y124, 나머지 상태는 y130. + if (!widget.unjoined) const SizedBox(height: 6), + _Contribution(growth: growth, joined: !widget.unjoined), + const SizedBox(height: AppSpace.s7), + // HERO TIME 안내·큰 글자처럼 아래가 길어지면 행성이 먼저 줄어든다. + Flexible( + child: LayoutBuilder( + builder: (context, box) => SizedBox( + width: double.infinity, + height: math.min(planet, box.maxHeight), + child: widget.unjoined + ? _EmptyPlanet( + onCreateNebula: widget.onCreateNebula, + onJoin: widget.onJoin, + ) + : growth.isNebula + ? _Nebula( + key: _planetKey, + // 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다. + ready: growth.nebulaReady && growth.isFounder, + onBirth: widget.onBirth, + ) + : Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: LivePlanet25D( + onOpenHistory: widget.onOpenHistory, + key: _planetKey, + fandomId: fandomId, + watch: watchPlanet, + stage: growth.stage, + image: 'assets/figma/planet-home.png', + size: math.min(planet, box.maxHeight), + glow: AppColors.neonViolet, + ), + ), + if (widget.miniMap case final mini?) + Positioned(top: 0, right: 0, child: mini), + ], + ), + ), + ), + ), + if (!widget.unjoined) ...[ + const SizedBox(height: AppSpace.s7), + HeroTimeStatus( + fandomId: fandomId, + watch: watchPlanet, + onParticipate: hasZap ? null : onHeroParticipate, + ), + _GrowthOverlayCard( + growth: growth, + onOpen: widget.onOpenPlanet, + zapClearance: hasZap, + ), + ], + ], + ), + ), + + if (widget.ticker case final ticker?) + Positioned(top: top + 102, left: 0, right: 0, child: ticker), + + // 3. 원형 ZAP — 사본 `Frame 1984081111`. 중심이 카드 아래 변에 걸친다. + if (widget.zapData case final data?) + if (widget.zap case final zap?) + if (data.joined && data.mine?.id == fandomId) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LiveStreamBuilder( + key: ValueKey(('home-zap', fandomId)), + create: () => watchPlanet(fandomId), + builder: (context, state) { + final hero = + state?.isHeroTime == true && + state?.heroEndsAt?.isAfter(DateTime.now()) == + true && + onHeroParticipate != null && + zap.pending == 0 && + !zap.sending && + !zap.needsRetry; + return Column( + key: _buttonKey, + mainAxisSize: MainAxisSize.min, + children: [ + if (zap.needsRetry) + TextButton( + onPressed: zap.retry, + child: Text(context.l10n.zapRetrySame), + ), + ZapButton( + round: true, + showLabel: !growth.isNebula, + onItem: widget.onItem, + zapPop: data.zapPop, + myPop: + (zap.balance ?? data.myPop) - + zap.pending * data.zapPop, + isHeroTime: hero, + blockedReason: zap.sending + ? context.l10n.zapApplying + : zap.needsRetry + ? context.l10n.zapNeedsCheck + : null, + onZap: () => hero + ? onHeroParticipate!() + : zap.tap(fandomId), + ), + ], + ); + }, + ), + ), + if (widget.zap case final zap?) + Positioned.fill( + child: IgnorePointer( + child: ZapFeedback( + touchSerial: zap.touchSerial, + pending: zap.pending > 0 || zap.sending, + result: zap.result, + source: _center(_buttonKey), + target: _center(_planetKey), + radius: + (_planetKey.currentContext?.findRenderObject() + is RenderBox + ? (_planetKey.currentContext!.findRenderObject() + as RenderBox) + .size + .shortestSide + : planet) * + 0.4, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 「BTS_Hong 님의 / 1,230 POP 기여로 꾸준하게 성장하고 있어요.」 — `Frame 1984081113`. +/// +/// 아바타 26 원 + 9 + 닉네임(ABeeZee 12 흰색) 2 「님의」(42dot 12 #A3A2A0), 3 아래 문구 +/// (42dot Medium 20 · 행간 140%). 아바타 줄에는 검정 70% 4 그림자. +class _Contribution extends StatelessWidget { + const _Contribution({required this.growth, required this.joined}); + + final PlanetGrowth growth; + + /// 소속 팬덤이 있는가. 없으면 첫 사용자 문구(`343:212939`)를 쓴다. + final bool joined; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 성운·첫 사용자 상태의 「{닉네임} 님의」는 기여 여부와 무관한 인사 줄이다. + if (growth.hasRecentContribution || + ((!joined || growth.isNebula) && + growth.contributorName.trim().isNotEmpty)) ...[ + Row( + children: [ + // 프로필 사진 자산이 데이터에 없어 이니셜로 그린다. + Container( + width: 26, + height: 26, + alignment: Alignment.center, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.cosmicSurfaceRaised, + boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)], + ), + child: Text( + growth.contributorName.characters.firstOrNull ?? '—', + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.onCosmic, + ), + ), + ), + const SizedBox(width: 9), + Text( + growth.contributorName, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 12, + height: 1.3, + letterSpacing: -0.3, + color: AppColors.onCosmic, + ), + ), + const SizedBox(width: 2), + Text( + context.l10n.heroOwnerSuffix, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.3, + letterSpacing: -0.3, + color: AppColors.onCosmicVariant, + ), + ), + ], + ), + const SizedBox(height: 3), + ], + Text( + // 사본 문구의 「팬케이크」는 사용자 노출 브랜드라 「팬우주」로 바꾼다(공통 기준 4항). + !joined + ? context.l10n.heroStartPrompt + : growth.isNebula + ? context.l10n.heroNebulaPrompt + : growth.hasRecentContribution + ? context.l10n.heroRecentContribution(formatPop(growth.contributedPop)) + : context.l10n.heroGrowingTogether, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 20, + fontWeight: FontWeight.w500, + height: 1.4, + letterSpacing: -0.5, + color: AppColors.onCosmic, + ), + ), + ], + ); +} + +/// 성장 카드 — 사본 기본 상태의 「BTS › / 행성 성장률 67% / 진행 바 / 6,270 / 10,000」 +/// (`Frame 1984081073` 343 폭 · `#040304` 40% · r12 · 패딩 12/16 · 간격 12 · 유리 흐림 4). +/// +/// 둘째 줄 「POP 진행률」과 바 아래 두 조건 줄은 정책값이다 — growthPercent 는 POP 진행률 +/// 이고(전체 충족률 아님) 승급은 POP·sFPI 동시 충족이다(정책 설계서 §P-08 · UX·FX v1.5 +/// §2.1). 말풍선 꼬리는 바 채움 끝을 가리킨다(사본 `Group 563` 가운데 = 채움 끝 x238). +class _GrowthOverlayCard extends StatelessWidget { + const _GrowthOverlayCard({ + required this.growth, + this.onOpen, + this.zapClearance = false, + }); + + final PlanetGrowth growth; + final VoidCallback? onOpen; + + /// 원형 ZAP(52)이 카드 아래 변에 걸친다. 둘째 조건 줄이 가려지지 않게 아래 여백을 + /// ZAP 윗변(아래 변 − 28) 위로 올린다. ZAP 좌표는 사본 그대로 둔다. + final bool zapClearance; + + static const _white14 = TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 24 / 14, + color: AppColors.onCosmic, + ); + + @override + Widget build(BuildContext context) { + final ratio = growth.popRatio; + + return ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.md), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB( + AppSpace.s4, + AppSpace.s3, + AppSpace.s4, + zapClearance ? 32 : AppSpace.s3, + ), + decoration: BoxDecoration( + color: const Color(0x66040304), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppFx.glassStroke), + ), + child: LayoutBuilder( + builder: (context, box) => Stack( + clipBehavior: Clip.none, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onOpen, + child: Row( + children: [ + Expanded( + child: Text( + growth.fandomName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 32, + height: 1.4, + letterSpacing: -0.8, + color: AppColors.onCosmic, + ), + ), + ), + if (onOpen != null) + const ImageIcon( + AppIcons.chevronRight, + size: 24, + color: AppColors.onCosmicVariant, + ), + ], + ), + ), + Row( + children: [ + Text(context.l10n.heroPopProgress, style: _white14), + const SizedBox(width: 4), + Text( + growth.growthPercent == null + ? '—' + : '${growth.growthPercent}%', + style: _white14, + ), + ], + ), + const SizedBox(height: AppSpace.s3), + _Bar(ratio: ratio ?? 0), + const SizedBox(height: AppSpace.s3), + GrowthConditions( + growth: growth, + style: _white14.copyWith(height: 16 / 14), + ), + ], + ), + // 말풍선 — 꼬리 끝이 바 위 9.5 에 닿는다(사본 581.5+40 → 바 631). + // 성운 카드(`344:213809`)에는 말풍선이 없다. + if (!growth.isNebula) + Positioned( + top: 45 + 24 + AppSpace.s3 - 49.5, + left: (box.maxWidth * (ratio ?? 0.5)).clamp( + 84.0, + box.maxWidth - 84, + ), + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: _SpeechBubble(text: growth.growthStatus), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 진행 바 — 10 높이 · 바탕 검정 30% · 채움 `#D496C4 → #FF7DDD`. +class _Bar extends StatelessWidget { + const _Bar({required this.ratio}); + + final double ratio; + + @override + Widget build(BuildContext context) => Container( + height: 10, + decoration: BoxDecoration( + color: const Color(0x4D000000), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: ratio, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.full), + gradient: const LinearGradient( + colors: [Color(0xFFD496C4), Color(0xFFFF7DDD)], + ), + ), + ), + ), + ); +} + +/// 말풍선 — 사본 `Union`(검정 80% · 본체 30 + 아래 꼬리 10) · 42dot Medium 12 흰색. +class _SpeechBubble extends StatelessWidget { + const _SpeechBubble({required this.text}); + + final String text; + + static const _fill = Color(0xCC000000); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.center, + decoration: BoxDecoration( + color: _fill, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + text, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1, + color: AppColors.onCosmic, + ), + ), + ), + CustomPaint( + size: const Size(10, 10), + painter: _TailPainter(color: _fill), + ), + ], + ); +} + +class _TailPainter extends CustomPainter { + const _TailPainter({required this.color, this.up = false}); + final Color color; + + /// 위를 가리키는 꼬리(말풍선이 대상 아래에 있을 때). + final bool up; + + @override + void paint(Canvas canvas, Size size) { + final path = up + ? (Path() + ..moveTo(0, size.height) + ..lineTo(size.width, size.height) + ..lineTo(size.width / 2, 0) + ..close()) + : (Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width / 2, size.height) + ..close()); + canvas.drawPath(path, Paint()..color = color); + } + + @override + bool shouldRepaint(_TailPainter old) => old.color != color || old.up != up; +} + +/// 성운 — 사본 `홈_성장 완료`(`344:213808` 212). 두 전환 조건을 채우면 아래 말풍선 +/// 「성운을 클릭해서 / 행성으로 탄생 시켜보세요!」(`Group 563` 181×53 · 꼬리 위)가 뜨고 +/// 누르면 행성 생성으로 간다. 채우기 전에는 눌러도 아무 일이 없다(B-10 동시 충족). +class _Nebula extends StatelessWidget { + const _Nebula({super.key, required this.ready, this.onBirth}); + + final bool ready; + final VoidCallback? onBirth; + + @override + Widget build(BuildContext context) => Center( + child: SizedBox( + width: 212, + height: 212, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: [ + // 사본 `Ellipse 40~45`(74×71 #D9D9D9) — 성운 뒤 왼쪽에 걸친 흰 원반. + Positioned( + left: -1, + top: 69.5, + child: Container( + width: 74, + height: 71, + decoration: const ShapeDecoration( + shape: OvalBorder(), + color: Color(0xFFD9D9D9), + ), + ), + ), + Semantics( + button: ready && onBirth != null, + label: context.l10n.heroNebula, + child: GestureDetector( + onTap: ready ? onBirth : null, + child: Image.asset( + 'assets/figma/nebula-home.png', + width: 212, + height: 212, + ), + ), + ), + if (ready) + Positioned( + top: 177, + child: IgnorePointer( + child: Column( + children: [ + CustomPaint( + size: const Size(12, 9), + painter: _TailPainter( + color: const Color(0xCC000000), + up: true, + ), + ), + Container( + width: 181, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xCC000000), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + context.l10n.heroNebulaTap, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); +} + +/// 소속이 없는 첫 사용자 — 사본 `홈_초기사용자`(`Frame 1984081310`): 검정 원 190 안에 +/// 검정 40 상자(r8 · 분홍 빛 4) + 「+」. 누르면 「행성 생성 (성운) / 행성 가입 / 취소」 시트. +class _EmptyPlanet extends StatelessWidget { + const _EmptyPlanet({this.onCreateNebula, this.onJoin}); + + final VoidCallback? onCreateNebula; + final VoidCallback? onJoin; + + Future _open(BuildContext context) async { + final pick = await showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: Colors.transparent, + barrierColor: const Color(0xB3000000), + builder: (context) => _StartSheet( + options: [ + (context.l10n.heroCreateNebula, onCreateNebula), + (context.l10n.heroJoinPlanet, onJoin), + ], + ), + ); + pick?.call(); + } + + @override + Widget build(BuildContext context) => Center( + child: Container( + width: 190, + height: 190, + alignment: Alignment.center, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xB3000000), // `Ellipse 20` 검정 · 투명도 0.7 + ), + child: Semantics( + button: true, + label: context.l10n.heroCreateOrJoin, + child: GestureDetector( + onTap: () => _open(context), + // `Frame 1984081024` 에셋(40 상자 + 분홍 빛 4 여백 + 「+」). + child: Image.asset( + 'assets/figma/home-plus-box.png', + width: 48, + height: 48, + ), + ), + ), + ), + ); +} + +/// 사본 `Frame 1984081055` — 좌우·아래 10 띄운 #2C2C2E r16 시트, 손잡이 36×4, 줄 높이 47. +class _StartSheet extends StatelessWidget { + const _StartSheet({required this.options}); + + final List<(String, VoidCallback?)> options; + + static const _item = TextStyle( + fontFamily: AppFonts.body, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + color: AppColors.onCosmic, + ); + + @override + // 사본은 홈 인디케이터 위가 아니라 화면 아래에서 10 띄운다. + Widget build(BuildContext context) => SafeArea( + bottom: false, + child: Container( + margin: const EdgeInsets.fromLTRB(10, 0, 10, 10), + padding: const EdgeInsets.fromLTRB(AppSpace.s4, 12, AppSpace.s4, 12), + decoration: BoxDecoration( + color: AppColors.cosmicSheet, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFF4F4F4F), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + const SizedBox(height: 8), + for (final (label, action) in options) + InkWell( + onTap: () => Navigator.of(context).pop(action), + child: SizedBox( + height: 47, + child: Align( + alignment: Alignment.centerLeft, + child: Text(label, style: _item), + ), + ), + ), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: SizedBox( + height: 47, + child: Center(child: Text(context.l10n.commonCancel, style: _item)), + ), + ), + ], + ), + ), + ); +} diff --git a/app/lib/features/home/widgets/inside_card.dart b/app/lib/features/home/widgets/inside_card.dart new file mode 100644 index 0000000..37b7cb8 --- /dev/null +++ b/app/lib/features/home/widgets/inside_card.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; + +/// 인기 K-INSIDE 한 건 — Figma 「아미보이 / 10분 전 / 500 P / 제목 / 본문 / 3」. +/// +/// 현상금(POP 바운티)이 붙은 Q&A 다. 정책 설계서 §P-11 — 자기 답변 채택은 막고 +/// (`E-QNA-01`), 기한 만료 시 현상금은 자동 환불한다(`E-QNA-02`). +class InsideCard extends StatelessWidget { + const InsideCard({super.key, required this.digest, this.onTap}); + + final InsideDigest digest; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Material( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(digest.authorName, style: t.bodySmall), + const SizedBox(width: AppSpace.s2), + Text(digest.timeAgo, style: t.labelSmall), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpace.s2, + vertical: 2, + ), + decoration: BoxDecoration( + border: Border.all(color: AppColors.cosmicAccent), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + '${formatPop(digest.bountyPop)} P', + style: t.labelSmall?.copyWith( + color: AppColors.cosmicAccent, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpace.s3), + Text(digest.title, style: t.bodyMedium), + const SizedBox(height: AppSpace.s1), + Text( + digest.body, + style: t.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpace.s3), + Row( + children: [ + const Icon( + Icons.mode_comment_outlined, + size: 14, + color: AppColors.onCosmicVariant, + ), + const SizedBox(width: AppSpace.s1), + Text( + context.l10n.insideAnswers('${digest.answers}'), + style: t.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/home/widgets/item_sheet.dart b/app/lib/features/home/widgets/item_sheet.dart new file mode 100644 index 0000000..7c96e40 --- /dev/null +++ b/app/lib/features/home/widgets/item_sheet.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; + +import '../../../core/data/backend.dart'; +import '../../../core/monetization/store.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/app_icons.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; + +/// 아이템 시트 — Figma 사본 「아이템 구입」(헤더 지갑 · `252:160233`) · +/// 「아이템 선택」(ZAP 옵션 「아이템」 · `343:204868`). +/// +/// 두 시트는 제목과 카드 아랫줄만 다르다 — 구입은 「보유 : N」 + 가격, 선택은 「보유 : N개」. +/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(원장 B-84)이고 +/// 결과는 앱 전역 알림으로 온다([Store.messages]). 사본의 결제 시트(「앱 내 구입」)는 OS 결제 +/// 창이라 앱이 그리지 않는다. 구매가 곧 발동이라 보유 재고가 없어 「선택」(사용)은 준비 중이다. +Future showItemSheet( + BuildContext context, { + required List items, + required bool purchase, + String fandomId = '', +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + // 사본처럼 하단 탭 위를 덮는다. + useRootNavigator: true, + backgroundColor: Colors.transparent, + barrierColor: const Color(0xB3000000), + builder: (_) => + _ItemSheet(items: items, purchase: purchase, fandomId: fandomId), +); + +class _ItemSheet extends StatelessWidget { + const _ItemSheet({ + required this.items, + required this.purchase, + required this.fandomId, + }); + + final List items; + final bool purchase; + final String fandomId; + + void _say(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future _tap(BuildContext context, ItemProduct item) async { + if (purchase && fandomId.isEmpty) { + // 결제 창을 열기 전에 막는다 — 서버도 소속 행성 없는 구매를 거부한다(E-PAY-01). + _say(context, context.l10n.itemJoinFirst); + return; + } + final store = Store.instance; + if (!purchase || store == null || item.productId.isEmpty) { + _say(context, purchase + ? context.l10n.itemBuyNotReady + : context.l10n.itemUseNotReady); + return; + } + try { + await store.buy(item.productId); + } on BackendException catch (error) { + if (context.mounted) _say(context, error.message); + } on Exception { + if (context.mounted) _say(context, context.l10n.payOpenFailed); + } + } + + @override + Widget build(BuildContext context) => Container( + // 사본 `Frame 1984081232` — #101010 · 위 r24 · 패딩 20/0 · 홈 인디케이터 자리까지 같은 색. + decoration: const BoxDecoration( + color: AppColors.cosmicSurface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.fromLTRB( + AppSpace.s4, + 20, + AppSpace.s4, + 20 + MediaQuery.paddingOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + children: [ + Expanded( + child: Text( + purchase ? context.l10n.itemBuy : context.l10n.itemSelect, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + ), + // 사본 X 는 12 글리프 — 행 높이(22)를 늘리지 않도록 IconButton 대신 쓴다. + Semantics( + button: true, + label: context.l10n.commonClose, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: const ImageIcon( + AppIcons.x, + size: 20, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpace.s5), + if (items.isEmpty) + SizedBox( + height: 91, + child: Center( + child: Text( + context.l10n.commonComingSoon, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.onCosmicMuted, + ), + ), + ), + ), + for (final (i, item) in items.indexed) ...[ + if (i > 0) const SizedBox(height: AppSpace.s4), + _ItemCard( + item: item, + purchase: purchase, + onTap: () => _tap(context, item), + ), + ], + ], + ), + ); +} + +/// 사본 `Button` — 343×91 · #1A1A1C · 1 테두리 #583118 · r8 · 패딩 8/12 · 간격 12. +class _ItemCard extends StatelessWidget { + const _ItemCard({ + required this.item, + required this.purchase, + required this.onTap, + }); + + final ItemProduct item; + final bool purchase; + final VoidCallback onTap; + + static const _chip = TextStyle( + fontFamily: AppFonts.body, + fontSize: 10, + fontWeight: FontWeight.w600, + height: 1, + ); + + Widget _tag(String text, Color fill, Color ink) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(2), + ), + child: Text(text, style: _chip.copyWith(color: ink)), + ); + + @override + Widget build(BuildContext context) => Material( + color: AppColors.cosmicSurfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + side: const BorderSide(color: Color(0xFF583118)), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + item.name, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + const SizedBox(width: 8), + _tag( + context.l10n.itemSendPop(formatPop(item.dropPop)), + const Color(0xFF512F24), + AppColors.cosmicOrange, + ), + const SizedBox(width: 6), + _tag( + context.l10n.itemBuyerPop(formatPop(item.buyerPop)), + const Color(0xFF204037), + const Color(0xFF43AD86), + ), + ], + ), + const SizedBox(height: 6), + Text( + item.description, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.4, + color: AppColors.onCosmicMuted, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + purchase + ? context.l10n.itemOwned('${item.owned}') + : context.l10n.itemOwnedCount('${item.owned}'), + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: purchase ? FontWeight.w500 : FontWeight.w600, + height: 1.4, + color: purchase + ? const Color(0xFFDDDDDD) + : AppColors.onCosmic, + ), + ), + const Spacer(), + if (purchase) + Text( + '₩${formatPop(item.priceWon)}', + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + ], + ), + ], + ), + ), + ), + ); +} diff --git a/app/lib/features/home/widgets/live_ticker.dart b/app/lib/features/home/widgets/live_ticker.dart new file mode 100644 index 0000000..cd5e1c4 --- /dev/null +++ b/app/lib/features/home/widgets/live_ticker.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; + +/// 상단 라이브 띠 — Figma 「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」. +/// +/// 사본 `Frame 1984081132` — 헤더 바로 아래 전폭 검정 띠(높이 22 · 패딩 4/0), 가운데 +/// 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의 표면이다. +class LiveTicker extends StatelessWidget { + const LiveTicker({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) => Container( + height: 22, + color: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + alignment: Alignment.center, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 14 / 12, + letterSpacing: -0.3, + color: AppColors.onCosmic, + ), + ), + ); +} diff --git a/app/lib/features/home/widgets/planet_growth_card.dart b/app/lib/features/home/widgets/planet_growth_card.dart new file mode 100644 index 0000000..7264336 --- /dev/null +++ b/app/lib/features/home/widgets/planet_growth_card.dart @@ -0,0 +1,89 @@ +import 'growth_conditions.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; + +/// 내 팬덤 행성의 성장 — Figma 홈의 「67% 행성 성장률 / 6,270 / 10,000」. +/// +/// 정책 근거는 정책 설계서 §P-08. Level-up 은 POP 과 sFPI 를 **동시에** 충족해야 +/// 하므로 서버가 제공한 두 조건을 각각 표시한다(UX·FX v1.5 §2.1). +class PlanetGrowthCard extends StatelessWidget { + const PlanetGrowthCard({super.key, required this.growth}); + + final PlanetGrowth growth; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final ratio = growth.popRatio; + + return Container( + padding: const EdgeInsets.all(AppSpace.s5), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.cosmicOutline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 「BTS_Hong 님의 1,230 POP 기여로 꾸준하게 성장하고 있어요.」 + if (!growth.hasRecentContribution) + Text(context.l10n.heroGrowingTogether, style: t.bodyMedium) + else Text.rich( + TextSpan( + children: [ + TextSpan( + text: growth.contributorName, + style: t.bodyMedium?.copyWith( + color: AppColors.cosmicAccent, + fontWeight: FontWeight.w600, + ), + ), + TextSpan(text: context.l10n.growthOwnerOf, style: t.bodyMedium), + TextSpan( + text: '${formatPop(growth.contributedPop)} POP', + style: t.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + TextSpan(text: context.l10n.growthSteadySuffix, style: t.bodyMedium), + ], + ), + ), + const SizedBox(height: AppSpace.s5), + + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + growth.growthPercent == null ? '—' : '${growth.growthPercent}%', + style: t.displayMedium?.copyWith(color: AppColors.cosmicAccent), + ), + const SizedBox(width: AppSpace.s2), + Padding( + padding: const EdgeInsets.only(bottom: AppSpace.s1), + child: Text(context.l10n.heroPopProgress, style: t.bodySmall), + ), + ], + ), + const SizedBox(height: AppSpace.s3), + + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: ratio ?? 0, + minHeight: 8, + backgroundColor: AppColors.cosmicSurfaceRaised, + valueColor: const AlwaysStoppedAnimation(AppColors.cosmicAccent), + ), + ), + const SizedBox(height: AppSpace.s2), + + GrowthConditions(growth: growth), + ], + ), + ); + } +} diff --git a/app/lib/features/home/widgets/quest_row.dart b/app/lib/features/home/widgets/quest_row.dart new file mode 100644 index 0000000..84e6a06 --- /dev/null +++ b/app/lib/features/home/widgets/quest_row.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../home_data.dart'; +import '../home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; +import '../../quest/widgets/time_attack_countdown.dart'; + +/// TODAY'S QUEST 한 줄 — Figma 「오늘의 출석체크 / 출석 완료 / +{보상} / 보상받기」. +/// +/// 보상 숫자는 Figma 의 50 이 아니라 서버 지급액과 같은 원천에서 온다. +/// +/// 상태별 오른쪽 버튼이 다르다. 정책 설계서 §P-05 — 조건 미충족 시 보상은 +/// 지급하지 않는다(`E-QST-01`). +class QuestRow extends StatelessWidget { + const QuestRow({super.key, required this.quest, this.onTap}); + + final QuestSummary quest; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Material( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(quest.title, style: t.bodyMedium), + const SizedBox(height: AppSpace.s1), + Text(_subtitle(quest), style: t.bodySmall), + ], + ), + ), + const SizedBox(width: AppSpace.s3), + Text( + _reward(quest), + style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent), + ), + const SizedBox(width: AppSpace.s3), + _Action(state: quest.state, onTap: onTap), + ], + ), + ), + ), + ); + } + + static String _subtitle(QuestSummary q) => switch (q.state) { + QuestState.claimable => appL10n.questAttended, + QuestState.inProgress => q.progressLabel ?? appL10n.questInProgressSpaced, + QuestState.done => appL10n.questRewardPaid, + }; +} + +class _Action extends StatelessWidget { + const _Action({required this.state, this.onTap}); + + final QuestState state; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return switch (state) { + QuestState.claimable => FilledButton( + onPressed: onTap, + style: FilledButton.styleFrom( + minimumSize: const Size(0, AppSizing.touchMinApp), + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmicAccent, + ), + child: Text(_claimLabel), + ), + QuestState.inProgress => OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, AppSizing.touchMinApp), + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + foregroundColor: AppColors.onCosmic, + side: const BorderSide(color: AppColors.cosmicOutline), + ), + child: Text(_startLabel), + ), + QuestState.done => Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2), + child: Text(_doneLabel, style: t.bodySmall), + ), + }; + } +} + +/// 사본 HOME 「TODAY'S QUEST」 버튼 문구(`252:159354` spec). 사본 HOME 에 없는 완료 상태는 +/// 퀘스트 화면(C 그룹)의 「보상 완료」와 맞춘다. +String get _claimLabel => appL10n.questClaim; +String get _startLabel => appL10n.questStart; +String get _doneLabel => appL10n.questRewardDone; + +/// 홈 「TODAY'S QUEST」 가로 카드 — 사본 `Frame 1984081083`(196×137 · #1A1A1C · r12 · +/// 패딩 12 · 간격 28): 제목 16 · 상태 14 #A3A2A0 · 오른쪽 「+보상」 20 #2AC28B, 아래 37 버튼 +/// (보상 가능 = 초록 #3EB58A 흰 글자 · 진행 중 = 흰 바탕 검정 글자). 누르면 퀘스트 목록으로 간다. +class QuestCard extends StatelessWidget { + const QuestCard({super.key, required this.quest, this.onTap}); + + final QuestSummary quest; + final VoidCallback? onTap; + + static const width = 196.0; + + @override + Widget build(BuildContext context) { + final (label, fill, ink) = switch (quest.state) { + QuestState.claimable => ( + _claimLabel, + const Color(0xFF3EB58A), + AppColors.onCosmic, + ), + QuestState.inProgress => (_startLabel, AppColors.onCosmic, Colors.black), + QuestState.done => ( + _doneLabel, + AppColors.cosmicSurfaceRaised, + AppColors.onCosmicMuted, + ), + }; + return Material( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.all(AppSpace.s3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quest.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + const SizedBox(height: 6), + // 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06). + if (quest.endsAt case final endsAt? + when quest.state != QuestState.done) + TimeAttackCountdown( + endsAt: endsAt, + builder: (context, left) => _subtitleText( + left == null + ? context.l10n.questTimeAttackClosed + : context.l10n.questTimeAttackLeft( + formatLeft(left), + ), + ), + ) + else + _subtitleText(QuestRow._subtitle(quest)), + ], + ), + ), + const SizedBox(width: AppSpace.s2), + Text( + _reward(quest), + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 20, + fontWeight: FontWeight.w600, + height: 1, + color: Color(0xFF2AC28B), + ), + ), + ], + ), + Container( + height: 37, + alignment: Alignment.center, + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + label, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1, + color: ink, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +Widget _subtitleText(String text) => Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.4, + color: AppColors.onCosmicVariant, + ), +); + +/// 운영값을 못 읽었으면 숫자를 지어내지 않는다. +String _reward(QuestSummary quest) => + quest.rewardPop == null ? 'POP' : '+${formatPop(quest.rewardPop!)}'; diff --git a/app/lib/features/planet/fpi_ranking_screen.dart b/app/lib/features/planet/fpi_ranking_screen.dart new file mode 100644 index 0000000..421d6ff --- /dev/null +++ b/app/lib/features/planet/fpi_ranking_screen.dart @@ -0,0 +1,489 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/tokens.dart'; +import 'planet_data.dart'; +import 'widgets/action_sheet.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 지표별 **행성** 랭킹 — Figma `546:14589`~`546:14639`. +/// +/// 명세 `546:14591` — *타이틀 = 선택 지표명 값 + RANKING* +/// 명세 `546:14628` — *소속 행성 지표 랭킹 영역 — 순위 · 순위 동향 · 행성명 · 지표 수치* +/// 명세 `546:14639` — *전체 지표 랭킹 영역 — 최초 30개 · 스크롤 시 10개씩 ·* +/// *동작: 사용자 랭킹 화면 이동* +/// +/// **지표 카드에서 여기로 온다.** 지표 카드의 값·전체 순위는 저장소의 같은 +/// `fpiAxes[축]`·`fpiRanks[축]` 이므로, 아래 「소속 행성」 줄과 카드가 언제나 +/// 같은 수치를 띄운다(발주사 검토회신서 4절). +/// +/// 모집단은 별첨 B 확정 — *정식 행성 중 최근 7 일 유효 활동 존재 행성*. +class FpiRankingScreen extends StatefulWidget { + const FpiRankingScreen({ + super.key, + required this.metric, + required this.repository, + }); + + final FpiMetric metric; + final PlanetRepository repository; + + @override + State createState() => _FpiRankingScreenState(); +} + +class _FpiRankingScreenState extends State { + late final Future _ranking = widget.repository.planetRanking( + metric: widget.metric, + ); + + void _openUsers(FpiRankRow planet) => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => FpiUserRankingScreen( + metric: widget.metric, + fandomId: planet.id, + planetName: planet.name, + repository: widget.repository, + ), + ), + ); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text('${widget.metric.label} RANKING')), + body: _RankingBody( + future: _ranking, + // 명세 `546:14628` — 소속 행성 영역은 목록과 별개로 상단에 고정한다. + myLabel: context.l10n.rankMyPlanet(widget.metric.label), + emptyLabel: context.l10n.rankPlanetEmpty, + onTapRow: _openUsers, + planets: true, + ), + ); +} + +/// 랭킹 필터 — 명세 `546:14690` *실시간 · 국가별 · 취소 · 디폴트 실시간 · 단일 선택*. +enum FpiRankScope { + live, + byCountry; + + String get label => switch (this) { + live => appL10n.rankScopeLive, + byCountry => appL10n.rankScopeCountry, + }; +} + +/// 지표별 **사용자** 랭킹 — Figma `546:14646`~`546:14713`. +/// +/// 명세 `546:14668` — *나의 랭킹 영역 — 내 순위 · 순위 변동값 · 닉네임 · 기여 값* +/// 명세 `546:14674` — *기여 값에 따른 랭킹 소팅 · 실시간 데이터 기준* +/// 명세 `546:14713` — *국가별 — 순위 · 순위동향 · 국가명 · 국가 지표 값* +/// +/// 행성 랭킹의 한 줄을 눌러 들어온다. 캠페인 기여 랭킹과 구조가 같지만 +/// **필터 축이 다르다** — 캠페인은 기간(실시간/일간/주간), 여기는 범위 +/// (실시간/국가별)다. 한 화면으로 합치면 어느 필터가 뜨는지를 다시 분기해야 한다. +class FpiUserRankingScreen extends StatefulWidget { + const FpiUserRankingScreen({ + super.key, + required this.metric, + required this.fandomId, + required this.planetName, + required this.repository, + }); + + final FpiMetric metric; + final String fandomId; + final String planetName; + final PlanetRepository repository; + + @override + State createState() => _FpiUserRankingScreenState(); +} + +class _FpiUserRankingScreenState extends State { + FpiRankScope _scope = FpiRankScope.live; + late Future _ranking = _load(); + + Future _load() => switch (_scope) { + FpiRankScope.live => widget.repository.userRanking( + metric: widget.metric, + fandomId: widget.fandomId, + ), + FpiRankScope.byCountry => widget.repository.countryRanking( + metric: widget.metric, + ), + }; + + @override + Widget build(BuildContext context) => Scaffold( + // 시안 `373:436695` — 제목 「USER RANKING」, 범위 필터는 본문 「실시간 랭킹 ⌄」. + appBar: AppBar(title: const Text('USER RANKING')), + body: _RankingBody( + future: _ranking, + // 국가별에서는 「나의 랭킹 영역」이 내 국가 행이다(546:14713). + myLabel: switch (_scope) { + FpiRankScope.live => context.l10n.rankMine(widget.metric.label), + FpiRankScope.byCountry => context.l10n.rankMyCountry( + widget.metric.label, + ), + }, + emptyLabel: context.l10n.rankEmpty, + scopeLabel: context.l10n.rankScope(_scope.label), + onPickScope: _pickScope, + ), + ); + + Future _pickScope() async { + FpiRankScope? picked; + await showActionSheet(context, [ + for (final scope in FpiRankScope.values) + ActionSheetItem(scope.label, () => picked = scope, destructive: false), + ]); + final next = picked; + if (next != null && next != _scope && mounted) { + setState(() { + _scope = next; + _ranking = _load(); + }); + } + } +} + +/// 두 랭킹 화면이 같은 몸통을 쓴다 — 명세가 「내 줄 + 목록」으로 같다. +/// +/// 시안 `Frame 1984081171`(안쪽 16 · 간격 24) — 제목 24/500 + 내 줄 카드, +/// 사용자 랭킹은 「실시간 랭킹 ⌄」 제목과 1~3위 시상대가 목록 앞에 온다. +class _RankingBody extends StatelessWidget { + const _RankingBody({ + required this.future, + required this.myLabel, + required this.emptyLabel, + this.onTapRow, + this.planets = false, + this.scopeLabel, + this.onPickScope, + }); + + final Future future; + final String myLabel; + final String emptyLabel; + final ValueChanged? onTapRow; + + /// 행성 랭킹이면 내 줄 아바타가 행성 그림이다. + final bool planets; + + /// 사용자 랭킹의 범위 제목과 그 선택. + final String? scopeLabel; + final VoidCallback? onPickScope; + + @override + Widget build(BuildContext context) => FutureBuilder( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final data = snap.data; + if (data == null) { + return Center(child: Text(context.l10n.rankLoadFailed)); + } + final podium = scopeLabel != null && data.rows.length >= 3; + final rows = podium ? data.rows.skip(3).toList() : data.rows; + return ListView( + padding: const EdgeInsets.all(AppSpace.s4), + children: [ + // 실데이터가 아니면 숨기지 않고 그대로 알린다. + if (data.mockNote case final note?) ...[ + MockBadge(note: note), + const SizedBox(height: AppSpace.s3), + ], + if (data.mine case final me?) ...[ + Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)), + const SizedBox(height: AppSpace.s4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF19191C), + borderRadius: BorderRadius.circular(8), + ), + child: _RankRow(row: me, index: -1, planet: planets, mark: false), + ), + const SizedBox(height: 24), + ], + if (scopeLabel case final label?) ...[ + InkWell( + onTap: onPickScope, + child: Row( + children: [ + Text( + label, + style: _rt( + 18, + AppColors.onCosmic, + FontWeight.w600, + height: null, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.keyboard_arrow_down, + size: 20, + color: AppColors.onCosmic, + ), + ], + ), + ), + const SizedBox(height: 12), + ], + if (data.rows.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Center(child: Text(emptyLabel)), + ), + if (podium) ...[ + _Podium(rows: data.rows.take(3).toList()), + const SizedBox(height: 12), + ], + for (final (i, row) in rows.indexed) + InkWell( + onTap: onTapRow == null ? null : () => onTapRow!(row), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: _RankRow(row: row, index: i), + ), + ), + ], + ); + }, + ); +} + +TextStyle _rt( + double size, + Color color, + FontWeight weight, { + double? height = 1, +}) => TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: weight, + height: height, + letterSpacing: height == null ? 0 : size * -0.025, + color: color, +); + +/// 랭킹 수치 — 시안 「782.2K」 · 「1.2K」 · 「3K」 · 「98」. +String _rankValue(int v) { + String trim(String s) => s.endsWith('.0') ? s.substring(0, s.length - 2) : s; + if (v >= 1000000) return '${trim((v / 1000000).toStringAsFixed(1))}M'; + if (v >= 1000) return '${trim((v / 1000).toStringAsFixed(1))}K'; + return '$v'; +} + +/// 아바타 자리 — 서버에 사진이 없어 시안의 색 원을 순서대로 쓴다. +const _avatarColors = [ + Color(0xFF000000), + Color(0xFF7160E8), + Color(0xFFF542E6), + Color(0xFFC8D48A), + Color(0xFFF2D0F5), + Color(0xFF8E1EA8), + Color(0xFFF2C2B8), + Color(0xFFF5F2B0), + Color(0xFFD2F5B8), +]; + +Widget _avatar(int index, double size, {bool planet = false}) => planet + ? Image.asset( + 'assets/figma/planet-detail-hero.png', + width: size, + height: size, + ) + : Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _avatarColors[index.abs() % _avatarColors.length], + ), + ); + +/// 순위 동향 — ▲n #f28180 · ▼n #6866f3 · 변동 없음 「-」 #777777(10/500). +class _Change extends StatelessWidget { + const _Change({required this.change}); + + final int change; + + @override + Widget build(BuildContext context) { + if (change == 0) + return Text( + '-', + style: _rt(10, const Color(0xFF777777), FontWeight.w500), + ); + final color = change > 0 ? const Color(0xFFF28180) : AppColors.neonViolet; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(change > 0 ? '▲' : '▼', style: _rt(8, color, FontWeight.w500)), + const SizedBox(width: 2), + Text('${change.abs()}', style: _rt(10, color, FontWeight.w500)), + ], + ); + } +} + +/// 랭킹 한 줄 — 순위 · 동향 | 28 | 아바타 40(원 36) · 6 · 이름 #979b9e | 수치 16/600. +class _RankRow extends StatelessWidget { + const _RankRow({ + required this.row, + required this.index, + this.planet = false, + this.mark = true, + }); + + final FpiRankRow row; + final int index; + final bool planet; + + /// 목록 안 내 행에 「(나)」를 붙인다. 위 내 줄 카드는 시안대로 이름만 둔다. + final bool mark; + + @override + Widget build(BuildContext context) => SizedBox( + height: 40, + child: Row( + children: [ + SizedBox( + width: 21, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${row.rank}', + style: _rt(14, AppColors.onCosmic, FontWeight.w600), + ), + const SizedBox(height: 2), + _Change(change: row.change), + ], + ), + ), + const SizedBox(width: 22), + // 사본 — 40 프레임 가운데 지름 36 원. + SizedBox.square( + dimension: 40, + child: Center(child: _avatar(index, 36, planet: planet)), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + row.isMine && mark ? context.l10n.rankMeSuffix(row.name) : row.name, + overflow: TextOverflow.ellipsis, + style: _rt(14, const Color(0xFF979B9E), FontWeight.w500), + ), + ), + Text( + _rankValue(row.value), + style: _rt(16, AppColors.onCosmic, FontWeight.w600, height: 1.4), + ), + ], + ), + ); +} + +/// 1~3위 시상대 — 가운데 1위, 양옆 2·3위는 20 내려 앉는다. 왕관 24(금 #fea300 · +/// 은 #acacac · 동 #9c724a) 아래 아바타 80, 이름 14/600 · 수치 16/600. +class _Podium extends StatelessWidget { + const _Podium({required this.rows}); + + final List rows; + + static const _crown = [ + Color(0xFFFEA300), + Color(0xFFACACAC), + Color(0xFF9C724A), + ]; + + Widget _place(BuildContext context, int i) => SizedBox( + width: 80, + child: Column( + children: [ + if (i > 0) const SizedBox(height: 20), + SizedBox.square( + dimension: 24, + child: Stack( + alignment: Alignment.center, + children: [ + Image.asset( + 'assets/figma/icon-crown.png', + width: 24, + height: 24, + color: _crown[i], + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '${i + 1}', + style: _rt(10, AppColors.onCosmic, FontWeight.w900), + ), + ), + ], + ), + ), + _avatar(i + 3, 80), + const SizedBox(height: 8), + Text( + rows[i].isMine ? context.l10n.rankMeSuffix(rows[i].name) : rows[i].name, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: _rt(14, AppColors.onCosmic, FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + _rankValue(rows[i].value), + style: _rt(16, AppColors.onCosmic, FontWeight.w600, height: 1.4), + ), + ], + ), + ); + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _place(context, 1), + const SizedBox(width: 24), + _place(context, 0), + const SizedBox(width: 24), + _place(context, 2), + ], + ); +} + +/// 실데이터가 아닌 자리를 숨기지 않고 그대로 알리는 배지 — 대조표 3-5. +/// 랭킹 화면과 행성 상세 FPI 탭이 같은 배지를 쓴다. +class MockBadge extends StatelessWidget { + const MockBadge({super.key, required this.note}); + + final String note; + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(AppSpace.s4, AppSpace.s3, AppSpace.s4, 0), + padding: const EdgeInsets.all(AppSpace.s3), + decoration: BoxDecoration( + color: AppColors.cosmicVoid, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.neonViolet), + ), + child: Text( + 'MOCK · $note', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: AppColors.neonViolet), + ), + ); +} diff --git a/app/lib/features/planet/hero_participation_sheet.dart b/app/lib/features/planet/hero_participation_sheet.dart new file mode 100644 index 0000000..ba01bfc --- /dev/null +++ b/app/lib/features/planet/hero_participation_sheet.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sensors_plus/sensors_plus.dart'; +import '../../core/data/planet_visual_state.dart'; +import '../../core/data/backend.dart'; +import '../../core/ui/live_stream.dart'; +import 'planet_data.dart'; +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}); + final String fandomId; + final PlanetWatch watch; + final PlanetRepository repository; + final Stream? accelerometer; + @override + State createState() => _HeroParticipationSheetState(); +} + +class _HeroParticipationSheetState extends State + with WidgetsBindingObserver { + StreamSubscription? _sensor; + PlanetVisualState? _state; + final _received = {}; + final _unavailable = {}; + String? _notice; + bool _accessDenied = false; + String? _pendingId; + bool _sending = false; + bool _failed = false; + bool _foreground = true; + DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0); + + @override + 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); + final now = DateTime.now(); + if (!magnitude.isFinite || magnitude < 18 || now.difference(_lastShake).inMilliseconds < 220) return; + _lastShake = now; + if (!_failed) _claim(); + }, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다. + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _foreground = state == AppLifecycleState.resumed; + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _sensor?.cancel(); + super.dispose(); + } + + 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(); + return ids.firstOrNull; + } + + Future _claim() async { + 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; }); + try { + final receipt = await widget.repository.claimHeroDrop(id); + if (!mounted) return; + setState(() { _received[id] = receipt; _pendingId = null; }); + if (_foreground) HapticFeedback.lightImpact(); + } catch (error) { + if (!mounted) return; + final code = error is BackendException ? error.code : null; + setState(() { + _notice = switch (code) { + 'E-POP-02' => context.l10n.heroGiftUnavailable, + 'E-POP-03' => context.l10n.heroGiftAlready, + 'E-AUTH-03' => context.l10n.heroGiftRelogin, + 'E-PERM-01' => context.l10n.heroGiftNoPermission, + _ => null, + }; + _failed = _notice == null; + if (!_failed) { + _pendingId = null; + _unavailable.add(id); + _accessDenied = code == 'E-AUTH-03' || code == 'E-PERM-01'; + } + }); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + @override + Widget build(BuildContext context) => LiveStreamBuilder( + key: ValueKey(widget.fandomId), + create: () => widget.watch(widget.fandomId), + builder: (context, 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), + ), + ]), + )); + }, + ); +} diff --git a/app/lib/features/planet/lounge_post_screens.dart b/app/lib/features/planet/lounge_post_screens.dart new file mode 100644 index 0000000..b4a68dc --- /dev/null +++ b/app/lib/features/planet/lounge_post_screens.dart @@ -0,0 +1,791 @@ +import 'package:firebase_core/firebase_core.dart' show FirebaseException; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../core/data/backend.dart' show BackendException; +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; +import '../../core/ui/relative_time.dart'; +import '../campaign/campaign_share.dart' show campaignShareText; +import '../my/my_data.dart' show PickedPhoto, PhotoRejected; +import '../my/photo_picker.dart'; +import '../translation/translation_comments.dart'; +import '../translation/translation_data.dart'; +import 'moderation.dart'; +import 'planet_data.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 글 상세 · 글 쓰기 — 화면 대조 원장 F3. +/// +/// Figma 노드 +/// - `244:22509` Galaxy/행성 상세/글 상세 · `373:439127` 홈/…/글 상세 +/// - `244:21746` Galaxy/행성 상세_팬덤 라운지/글 쓰기 · `373:437905` 홈/…/글 쓰기 +/// - 신고·차단(`252:162674` · `252:163306` · `251:51469`)은 [ReportScreen] 공용 + +/// 글 쓰기가 돌려주는 값. +class PostDraft { + const PostDraft({ + required this.title, + required this.body, + this.photos = const [], + }); + final String title; + final String body; + + /// 첨부 사진. 1차 검증([checkLoungePhotos])을 지난 것만 담긴다. + final List photos; +} + +/// 라운지 쓰기 실패를 사용자 문구로. 사유가 있는 실패는 그 문구를, 규칙 거부는 +/// 권한 안내를, 나머지는 재시도 안내를 띄운다. +String loungeError(Object error) => switch (error) { + PhotoRejected(:final message) => message, + BackendException(:final message) => message, + FirebaseException(code: 'permission-denied') => appL10n.loungeMembersOnly, + _ => appL10n.loungeFailed, +}; + +/// 라운지 글 공유 — 시스템 공유 시트로 본문 문구를 보낸다. +Future shareLoungePost(LoungePost post) => SharePlus.instance.share( + ShareParams( + text: campaignShareText( + title: loungePostText(post), + topic: appL10n.loungeTitle, + ), + ), +); + +void _toast(BuildContext context, String message) => + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + +/// 글 한 편의 본문. 설계서 §2.6 `posts` 에는 제목이 없어(서버 글은 [LoungePost.title] +/// 이 빈 값) Figma 도 본문 한 덩어리만 둔다. 표본 글처럼 제목이 있으면 첫 줄로 붙인다. +String loungePostText(LoungePost p) => + [p.title, p.body].where((s) => s.isNotEmpty).join('\n'); + +/// Figma 글 문구 공통 — 42dot Sans 500 · 행간 130% · 자간 -2.5%. +TextStyle loungeText(double size, Color color) => TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: FontWeight.w500, + height: 1.3, + letterSpacing: size * -0.025, + color: color, +); + +/// 에셋 경로 또는 URL. +ImageProvider loungeImage(String src) => + src.startsWith('http') ? NetworkImage(src) : AssetImage(src); + +/// 작성자 줄 — 아바타 26 · 간격 9 · 닉네임 ABeeZee 12 / 시각 10 #767676. +class LoungeAuthorRow extends StatelessWidget { + const LoungeAuthorRow({super.key, required this.post, this.trailing}); + + final LoungePost post; + final Widget? trailing; + + @override + Widget build(BuildContext context) => Row( + children: [ + CircleAvatar( + radius: 13, + backgroundColor: const Color(0xFFD9D9D9), + backgroundImage: switch (post.authorPhoto) { + final p? => loungeImage(p), + null => null, + }, + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.authorName, + style: loungeText(12, AppColors.onCosmic).copyWith( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + ), + ), + Text( + relativeTime(post.createdAt), + style: loungeText(10, AppColors.onCosmicMuted), + ), + ], + ), + ), + ?trailing, + ], + ); +} + +/// 좋아요 · 댓글 수. 수가 0 이면 아이콘만 둔다(Figma 글 상세 첫 화면 `244:22509`). +class LoungeCounts extends StatelessWidget { + const LoungeCounts({ + super.key, + required this.post, + required this.icon, + required this.color, + this.onLike, + }); + + final LoungePost post; + final double icon; + final Color color; + + /// 하트를 누르면 좋아요를 켜고 끈다. null 이면 수만 보인다. + final VoidCallback? onLike; + + @override + Widget build(BuildContext context) { + Widget item(String asset, int n, [Color? tint]) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset(asset, width: icon, height: icon, color: tint ?? color), + if (n > 0) ...[ + const SizedBox(width: 4), + Text( + '$n', + style: loungeText(icon <= 14 ? 12 : 14, color).copyWith(height: 1), + ), + ], + ], + ); + final heart = item( + 'assets/figma/icon-heart.png', + post.likes, + post.liked ? AppColors.cosmicAccent : null, + ); + return Row( + children: [ + if (onLike == null) + heart + else + Semantics( + button: true, + label: post.liked ? context.l10n.likeCancel : context.l10n.like, + child: InkResponse(onTap: onLike, radius: 18, child: heart), + ), + const SizedBox(width: 12), + item('assets/figma/icon-message-circle.png', post.comments), + ], + ); + } +} + +/// 글 상세. 좋아요 · 댓글 · 공유 · 신고 · 차단 · 내 글 수정 · 삭제가 여기서 일어난다. +/// +/// 바뀐 글은 [onChanged] 로 목록에 알린다 — 목록이 같은 글을 다시 읽지 않아도 +/// 수가 맞는다. `null` 을 보내면 글이 목록에서 빠진다(삭제 · 차단). +class LoungePostScreen extends StatefulWidget { + const LoungePostScreen({ + super.key, + required this.post, + this.repository = const SamplePlanetRepository(), + this.onChanged, + this.translation, + }); + + final LoungePost post; + final PlanetRepository repository; + final ValueChanged? onChanged; + + /// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다. + final TranslationRepository? translation; + + @override + State createState() => _LoungePostScreenState(); +} + +class _LoungePostScreenState extends State { + late LoungePost _post = widget.post; + List? _comments; + + /// 번역할 댓글. 없으면 번역 댓글은 글 본문의 번역이다(🟡 Q-TR-07 권고 A안). + TranslationReplyTarget? _replyTo; + + TranslationRepository? get _translation => + translationSurfaces.contains(TranslationSurface.lounge) + ? widget.translation ?? TranslationScope.maybeOf(context) + : null; + + String get _root => 'posts/${_post.id}'; + + @override + void initState() { + super.initState(); + _loadComments(); + } + + Future _loadComments() async { + try { + final list = await widget.repository.comments(_post.id); + if (mounted) setState(() => _comments = list); + } catch (e) { + if (mounted) { + setState(() => _comments = const []); + _toast(context, loungeError(e)); + } + } + } + + void _update(LoungePost post) { + setState(() => _post = post); + widget.onChanged?.call(post); + } + + Future _run(Future Function() action) async { + try { + await action(); + } catch (e) { + if (mounted) _toast(context, loungeError(e)); + } + } + + Future _like() => + _run(() async => _update(await widget.repository.toggleLike(_post))); + + Future _send(String body) async { + try { + final c = await widget.repository.addComment(_post.id, body); + if (!mounted) return true; + setState(() => _comments = [...?_comments, c]); + _update(_post.copyWith(comments: _post.comments + 1)); + return true; + } catch (e) { + if (mounted) _toast(context, loungeError(e)); + return false; + } + } + + Future _deleteComment(LoungeComment c) async { + if (!await confirmOverlay(context, context.l10n.commentDeleteConfirm)) + return; + await _run(() async { + await widget.repository.deleteComment(_post.id, c.id); + setState(() => _comments = [...?_comments]..remove(c)); + _update( + _post.copyWith(comments: _post.comments > 0 ? _post.comments - 1 : 0), + ); + }); + } + + @override + Widget build(BuildContext context) { + final post = _post; + final comments = _comments; + final translation = _translation; + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + tooltip: context.l10n.commonShare, + onPressed: () => _run(() => shareLoungePost(post)), + icon: const ImageIcon(AppIcons.share, size: 24), + ), + PopupMenuButton( + tooltip: context.l10n.commonMore, + icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24), + onSelected: (v) => _moderate(context, v), + itemBuilder: (_) => post.isMine + ? [ + PopupMenuItem( + value: 'edit', + child: Text(context.l10n.commonEditAction), + ), + PopupMenuItem( + value: 'delete', + child: Text(context.l10n.commonDeleteAction), + ), + ] + : [ + PopupMenuItem( + value: 'report', + child: Text(context.l10n.reportTitle), + ), + PopupMenuItem( + value: 'block', + child: Text(context.l10n.commonBlockAction), + ), + ], + ), + ], + ), + body: Column( + children: [ + Expanded( + // 시안 `Frame 4241` — 검정 바탕 위에 글 · 댓글 두 판을 8 간격으로 쌓는다. + child: ColoredBox( + color: Colors.black, + child: ListView( + padding: EdgeInsets.zero, + children: [ + ColoredBox( + color: AppColors.cosmicVoid, + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LoungeAuthorRow(post: post), + const SizedBox(height: 24), + Text( + loungePostText(post), + style: loungeText(16, AppColors.onCosmic), + ), + for (final (i, src) in post.images.indexed) ...[ + const SizedBox(height: 12), + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack( + fit: StackFit.expand, + children: [ + Image( + image: loungeImage(src), + fit: BoxFit.cover, + ), + // 시안 `244:22509` — 영상 길이 「00:36」 15/500 흰색, 오른쪽 아래 10. + if (i == 0 && post.videoDuration != null) + Positioned( + right: 10, + bottom: 10, + child: Text( + _mmss(post.videoDuration!), + style: loungeText( + 15, + AppColors.onCosmic, + ).copyWith(height: 1), + ), + ), + ], + ), + ), + ), + ], + const SizedBox(height: AppSpace.s4), + LoungeCounts( + post: post, + icon: 18, + color: const Color(0xFFD5D7D9), + onLike: _like, + ), + ], + ), + ), + ), + const SizedBox(height: 8), + if (comments != null) + switch (translation) { + final t? => TranslationThreads( + repository: t, + rootPath: _root, + builder: (_, byTarget) => + _commentList(comments, t, byTarget), + ), + null => _commentList(comments, null, const {}), + }, + ], + ), + ), + ), + CommentInputBar( + hint: context.l10n.commentHint, + onComment: _send, + // 규칙의 댓글 상한과 같은 수. + maxLength: 1000, + translation: translation, + rootPath: _root, + replyTo: _replyTo, + onClearReply: () => setState(() => _replyTo = null), + ), + ], + ), + ); + } + + /// 댓글 판 — 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에, 글 본문의 번역 댓글은 + /// 댓글 뒤에 붙인다. 둘 다 없으면 시안의 빈 상태. + Widget _commentList( + List comments, + TranslationRepository? t, + Map> byTarget, + ) { + final ownTranslations = byTarget[_root] ?? const []; + if (comments.isEmpty && ownTranslations.isEmpty) { + return ColoredBox( + color: AppColors.cosmicVoid, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Image.asset( + 'assets/figma/icon-message-circle.png', + width: 48, + height: 48, + color: const Color(0xFF4F4F4F), + ), + const SizedBox(height: AppSpace.s4), + Text( + context.l10n.commentFirst, + style: loungeText(12, AppColors.onCosmicVariant), + ), + ], + ), + ), + ); + } + const gap = EdgeInsets.fromLTRB(16, 0, 16, 12); + return ColoredBox( + color: AppColors.cosmicVoid, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final c in comments) ...[ + _CommentRow( + comment: c, + onDelete: c.isMine ? () => _deleteComment(c) : null, + onTranslate: t == null + ? null + : () => setState( + () => _replyTo = ( + path: '$_root/comments/${c.id}', + name: c.authorName, + ), + ), + ), + for (final e in byTarget['$_root/comments/${c.id}'] ?? const []) + Padding( + padding: gap, + child: TranslationCommentCard( + entry: e, + repository: t!, + reply: true, + ), + ), + ], + for (final e in ownTranslations) + Padding( + padding: gap, + child: TranslationCommentCard(entry: e, repository: t!), + ), + ], + ), + ); + } + + Future _moderate(BuildContext context, String action) async { + final post = _post; + switch (action) { + case 'edit': + 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) _update(updated); + case 'delete': + if (!await confirmOverlay(context, context.l10n.postDeleteConfirm)) + return; + await _run(() async { + await widget.repository.deletePost(post); + widget.onChanged?.call(null); + if (mounted) Navigator.pop(this.context); + }); + case 'report': + final reason = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget( + authorName: post.authorName, + title: loungePostText(post), + ), + ), + ), + ); + if (reason == null) return; + await _run(() async { + await widget.repository.reportPost(post, reason); + if (mounted) _toast(this.context, context.l10n.reportReceived); + }); + case 'block': + if (!await confirmBlock(context, post.authorName)) return; + await _run(() async { + await widget.repository.blockAuthor(post); + widget.onChanged?.call(null); + // 명세 — 차단 처리 후 목록으로 돌아간다. + if (mounted) Navigator.pop(this.context); + }); + } + } +} + +/// 댓글 한 줄 — 닉네임 · 작성 시각 · 본문. 내 댓글에만 삭제가 붙는다. +/// [onTranslate] 는 이 댓글을 골라 번역 댓글을 다는 지구본(시안 라운지 변형 `252:163306`). +class _CommentRow extends StatelessWidget { + const _CommentRow({required this.comment, this.onDelete, this.onTranslate}); + + final LoungeComment comment; + final VoidCallback? onDelete; + final VoidCallback? onTranslate; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${comment.authorName} · ${relativeTime(comment.createdAt)}', + style: loungeText(12, AppColors.onCosmicMuted), + ), + const SizedBox(height: 4), + Text(comment.body, style: loungeText(14, AppColors.onCosmic)), + ], + ), + ), + if (onTranslate != null) + IconButton( + tooltip: context.l10n.translationWrite, + visualDensity: VisualDensity.compact, + onPressed: onTranslate, + icon: const ImageIcon( + AppIcons.globe, + size: 16, + color: AppColors.onCosmicMuted, + ), + ), + if (onDelete != null) + TextButton( + onPressed: onDelete, + style: TextButton.styleFrom( + foregroundColor: AppColors.onCosmicMuted, + ), + child: Text(context.l10n.commonDelete), + ), + ], + ), + ); +} + +/// 글 쓰기 — 시안 `244:21746`. 제목 칸 없이 본문 한 칸(설계서 §2.6 posts 에 제목 없음). +/// +/// [onSubmit] 이 저장을 맡는다. 실패하면 이 화면에 남아 사유를 띄우므로 쓴 글이 +/// 날아가지 않는다. 성공하면 [onSubmit] 이 돌려준 글을 들고 닫힌다. +class LoungeComposeScreen extends StatefulWidget { + const LoungeComposeScreen({ + super.key, + this.initialBody = '', + this.allowPhotos = true, + this.picker = pickPhotoWithPlugin, + this.onSubmit, + }); + + final String initialBody; + + /// 수정 화면은 사진을 바꾸지 않는다 — 본문만 고친다(규칙상 `imageUrls` 도 + /// 고칠 수 있으나 업로드 · 삭제 동선이 한 벌 더 필요해진다). + final bool allowPhotos; + final PhotoPicker picker; + final Future Function(PostDraft draft)? onSubmit; + + @override + State createState() => _LoungeComposeScreenState(); +} + +class _LoungeComposeScreenState extends State { + late final _body = TextEditingController(text: widget.initialBody); + final _photos = []; + bool _saving = false; + + bool get _ready => _body.text.trim().isNotEmpty && !_saving; + + @override + void initState() { + super.initState(); + _body.addListener(() => setState(() {})); + } + + @override + void dispose() { + _body.dispose(); + super.dispose(); + } + + /// 사진 한 장을 골라 1차 검증(B-44 — 5 장 · 10MB · 이미지 형식)을 지난 것만 붙인다. + Future _pick(PhotoSource source) async { + try { + final photo = await widget.picker(source); + if (photo == null) return; + checkLoungePhotos([..._photos, photo]); + setState(() => _photos.add(photo)); + } catch (e) { + if (mounted) _toast(context, loungeError(e)); + } + } + + Future _submit() async { + final draft = PostDraft( + title: '', + body: _body.text.trim(), + photos: List.unmodifiable(_photos), + ); + final submit = widget.onSubmit; + if (submit == null) { + Navigator.pop(context); + return; + } + setState(() => _saving = true); + try { + final post = await submit(draft); + if (mounted) Navigator.pop(context, post); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + _toast(context, loungeError(e)); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: const CloseButton(), + title: Text( + widget.initialBody.isEmpty + ? context.l10n.postWrite + : context.l10n.postEdit, + ), + actions: [ + TextButton( + onPressed: _ready ? _submit : null, + style: TextButton.styleFrom( + foregroundColor: AppColors.onCosmic, + disabledForegroundColor: const Color(0xFF474747), + textStyle: loungeText( + 14, + AppColors.onCosmic, + ).copyWith(fontWeight: FontWeight.w600, height: 1), + minimumSize: const Size(56, 56), + ), + child: Text(context.l10n.commonDone), + ), + ], + ), + body: SafeArea( + child: Column( + children: [ + if (_saving) const LinearProgressIndicator(), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 22, 16, 16), + child: TextField( + controller: _body, + // 규칙의 글 상한과 같은 수. + maxLength: 5000, + buildCounter: _noCounter, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: loungeText(15, AppColors.onCosmic), + decoration: InputDecoration( + hintText: context.l10n.loungeHint, + hintStyle: loungeText( + 15, + const Color(0xFF6B6B6B), + ).copyWith(height: 1), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ), + // 고른 사진 — 누르면 뺀다. + if (_photos.isNotEmpty) + SizedBox( + height: 64, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _photos.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (_, i) => GestureDetector( + onTap: () => setState(() => _photos.removeAt(i)), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.memory( + _photos[i].bytes, + width: 64, + height: 64, + fit: BoxFit.cover, + semanticLabel: context.l10n.photoRemove('${i + 1}'), + ), + ), + ), + ), + ), + // 사진 첨부 — 시안 하단 도구 줄(카메라 · 앨범 28, 간격 20). + if (widget.allowPhotos) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + for (final (asset, label, source) in [ + ( + 'assets/figma/icon-compose-camera.png', + context.l10n.camera, + PhotoSource.camera, + ), + ( + 'assets/figma/icon-image.png', + context.l10n.album, + PhotoSource.gallery, + ), + ]) ...[ + InkResponse( + radius: 20, + onTap: _saving ? null : () => _pick(source), + child: Image.asset( + asset, + width: 28, + height: 28, + semanticLabel: label, + ), + ), + const SizedBox(width: 20), + ], + ], + ), + ), + ], + ), + ), + ); +} + +/// 글자 수 표시는 시안에 없다 — 상한만 건다. +Widget? _noCounter( + BuildContext context, { + required int currentLength, + required bool isFocused, + required int? maxLength, +}) => null; + +String _mmss(Duration d) => + '${d.inMinutes.toString().padLeft(2, '0')}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; diff --git a/app/lib/features/planet/moderation.dart b/app/lib/features/planet/moderation.dart new file mode 100644 index 0000000..8ff68a6 --- /dev/null +++ b/app/lib/features/planet/moderation.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_radio.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 신고·차단 — Figma 명세를 공용으로 뺐다. +/// +/// 성운 상세 · 행성 상세 · 글 상세 · 팬덤 라운지 네 곳에서 같은 흐름이 반복된다 +/// (화면 대조 원장 F3 의 4 · 5 · 8 · 9 · 86번). 화면마다 따로 만들면 문구와 +/// 동작이 갈라지므로 하나로 둔다. +/// +/// Figma 노드 +/// - `373:442007` Galaxy/성운 상세/신고하기 · `373:442417` Galaxy/성운 상세/차단하기 +/// - `252:162674` 글 상세/신고하기 · `252:163306` 글 상세/차단하기 +/// +/// 명세 +/// - 신고 정보 영역 — *작성자 닉네임 · 게시물 제목* +/// - 신고 사유 선택 — ***어드민에 등록된 신고 사유 항목*** · *디폴트 미선택 · 단수 선택* +/// - 신고 안내 모달 — *화면 중앙 오버레이 · 취소/확인* +/// - 차단 안내 모달 — *확인 시 차단 처리 후 HOT ARCHIVE 화면 이동* + +/// 신고 사유. 명세가 **어드민 등록 항목**이라 하므로 상수로 박지 않고 주입받는다. +/// 정책 설계서 §P-11 — 신고 처리는 전건 `AdminActionLog` 에 남는다. +/// 기본값은 Figma 사본 `373:442007` 의 네 항목이다. +List get defaultReportReasons => [ + appL10n.reportDefamation, + appL10n.reportInappropriate, + appL10n.reportSpam, + appL10n.reportPrivacy, +]; + +/// 신고 사유 → `reports.reasonCode`. 규칙(`firestore.rules` reports)이 이 네 코드만 +/// 받는다. 화면 문구가 바뀌어도 Admin 이 집계하는 코드는 그대로다. +Map get reportReasonCodes => { + appL10n.reportDefamation: 'defamation', + appL10n.reportInappropriate: 'inappropriate', + appL10n.reportSpam: 'spam', + appL10n.reportPrivacy: 'privacy', +}; + +/// 신고 대상. +class ModerationTarget { + const ModerationTarget({required this.authorName, required this.title}); + + final String authorName; + + /// 게시물 제목. 프로필을 신고할 때는 닉네임을 그대로 쓴다. + final String title; +} + +/// 신고하기 화면. +class ReportScreen extends StatefulWidget { + const ReportScreen({ + super.key, + required this.target, + this.reasons, + }); + + final ModerationTarget target; + final List? reasons; + + @override + State createState() => _ReportScreenState(); +} + +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, + ); + + // 시안 `373:442007` — 닫기 X · 「신고하기」 · 오른쪽 「신고」(미선택 #474747), + // 작성자(#ee0c2c)/제목 줄, 「신고 사유」 띠 #232325, 사유 줄 48(라디오 20 · 16/600 · 밑줄 #313131). + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: const CloseButton(), + title: Text(context.l10n.reportTitle), + actions: [ + TextButton( + onPressed: _reason == null ? null : _submit, + style: TextButton.styleFrom( + foregroundColor: AppColors.onCosmic, + disabledForegroundColor: const Color(0xFF474747), + textStyle: _t(14, AppColors.onCosmic), + minimumSize: const Size(56, 56), + ), + child: Text(context.l10n.reportSubmit), + ), + ], + ), + body: ListView( + padding: EdgeInsets.zero, + children: [ + // 신고 정보 영역 — 작성자 닉네임 · 게시물 제목 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + children: [ + _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)), + ], + ), + ), + Container( + color: const Color(0xFF232325), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Text(context.l10n.reportReasonLabel, style: _t(12, const Color(0xFF909091))), + ), + // 디폴트 미선택 · 단수 선택. + for (final r in widget.reasons ?? defaultReportReasons) + InkWell( + onTap: () => setState(() => _reason = r), + child: Container( + height: 48, + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF313131))), + ), + child: Row( + children: [ + AppRadio(selected: _reason == r), + const SizedBox(width: 8), + Text(r, style: _t(16, const Color(0xFFD1D1D2))), + ], + ), + ), + ), + ], + ), + ); + + Widget _info(String label, String value, Color color) => Row( + children: [ + SizedBox( + width: 36, + 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)), + ), + ], + ); + + Future _submit() async { + final ok = await confirmOverlay( + context, + context.l10n.reportConfirm, + ); + if (!ok || !mounted) return; + Navigator.pop(context, _reason); + } +} + +/// 차단 안내 모달. 확인하면 차단 처리 후 목록으로 돌아간다. +/// +/// 문구는 시안 그대로 — 글·회원은 「이 회원의 모든 게시물을 차단할까요?」(`252:163306`), +/// 성운은 「이 성운을 차단할까요?」(`373:442417`). [nickname] 은 부르는 쪽 호환용이다. +Future confirmBlock( + BuildContext context, + String nickname, { + String? message, +}) => confirmOverlay(context, message ?? context.l10n.blockMemberConfirm); + +/// 화면 중앙 오버레이 — 시안 `Frame 1984081031`(256 폭 · #2d2d2d r12 · 문구 14/400 · +/// 취소/확인 42 · 16/400 #1895ff · 가르는 선 #636463). +Future confirmOverlay(BuildContext context, String message) async { + const line = BorderSide(color: Color(0xFF636463), width: 0.5); + Widget button(BuildContext c, String label, bool value) => Expanded( + child: InkWell( + onTap: () => Navigator.pop(c, value), + child: SizedBox( + height: 42, + child: Center( + child: Text( + label, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w400, + color: Color(0xFF1895FF), + ), + ), + ), + ), + ), + ); + final r = await showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.6), + builder: (c) => Dialog( + backgroundColor: AppColors.cosmicDialog, + insetPadding: const EdgeInsets.symmetric(horizontal: 59.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12), + child: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.onCosmic, + ), + ), + ), + Container( + decoration: const BoxDecoration(border: Border(top: line)), + child: Row( + children: [ + button(c, c.l10n.commonCancel, false), + Container(width: 0.5, height: 42, color: const Color(0xFF636463)), + button(c, c.l10n.commonConfirm, true), + ], + ), + ), + ], + ), + ), + ); + return r ?? false; +} diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart new file mode 100644 index 0000000..2eb86b4 --- /dev/null +++ b/app/lib/features/planet/planet_data.dart @@ -0,0 +1,651 @@ +/// 행성·성운 상세가 그리는 값의 형태. +/// +/// 근거는 Figma `03_Wireframe` 의 명세 구간이다. +/// - TOTAL FPI 영역 — *TOTAL FPI 값 · 육각형 그래프 · 실시간 데이터 기준* +/// - FPI 상세 리스트 — *지표별 값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값* +/// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출* +library; + +import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto; +import '../../core/i18n/app_locale.dart'; + +/// FPI 지표 하나. **별첨 B 「FPI 5 축」 확정**(2026-09-09) — +/// `BPM / HEALTH / REFERRAL / CONTRIBUTION / COMMUNITY`. +/// +/// 앱 Figma 는 `BMP · STEPS · REFERAL · CONTRIBUTION · COMMUNITY` 로 적었으나 +/// 별첨 B 가 상위 기준이므로 화면 표기·**나열 순서**를 별첨 B 로 맞춥니다. +/// [name] 이 그대로 Firestore `fpiAxes` 의 키다(설계서 §4.1). +/// +/// **별첨 B 「FPI S/A/B」 = 삭제.** Figma `179:29160` 이 지표 카드에 「내 기여도 +/// 등급(S/A/B)」을 두었으나 별첨 B 가 삭제로 확정했으므로 모델에 두지 않는다. +enum FpiMetric { + bpm('BPM'), + health('HEALTH'), + referral('REFERRAL'), + contribution('CONTRIBUTION'), + community('COMMUNITY'); + + const FpiMetric(this.label); + + /// 답변서 v1.1 Q3 확정 표기. 화면에 띄우는 이름이다. + final String label; +} + +/// 지표별 값. 명세 — *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*. +class FpiScore { + const FpiScore({ + required this.metric, + required this.value, + required this.rank, + required this.trend, + required this.myTopPercent, + }); + + final FpiMetric metric; + final int value; + + /// 전체 순위. + final int rank; + + /// 최근 동향. 지난 집계 대비 증감. + final int trend; + + /// 내 기여도 상위 %. 작을수록 상위다. **서버 산출값이 아직 없으면 null** — + /// 없는 값을 100%(최하위)로 지어내면 화면이 그것을 사실로 띄운다. 대조표 3-5. + final int? myTopPercent; + + /// 육각형 그래프용 0~1 정규화. 지표별 상한이 다르므로 화면이 아니라 + /// 여기서 맞춘다. + double normalized(int max) => max == 0 ? 0 : (value / max).clamp(0.0, 1.0); +} + +/// 공동 순위 — 정책 원장 **C-19**. +/// +/// **실제 산정값(반올림 전)** 내림차순. 값이 같으면 같은 순위이고 다음 순위는 +/// 건너뛴다(1·1·3). 동점 내부 배치는 [id] 오름차순으로 고정한다 — 최근 활동 +/// 시각으로 차등하지 않는다. 표시가 반올림으로 같아도 실값이 다르면 다른 순위다. +List<(T, int)> rankWithTies( + Iterable items, + 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 out = <(T, int)>[]; + for (var i = 0; i < sorted.length; i++) { + final tied = i > 0 && value(sorted[i]) == value(sorted[i - 1]); + out.add((sorted[i], tied ? out[i - 1].$2 : i + 1)); + } + return out; +} + +/// 랭킹 한 줄. 행성·사용자·국가 랭킹이 모두 같은 모양이라 하나로 둔다 +/// (Figma `546:14629` · `546:14674` · `546:14713` — 순위·순위동향·이름·지표 수치). +class FpiRankRow { + const FpiRankRow({ + required this.rank, + required this.id, + required this.name, + required this.value, + this.change = 0, + this.isMine = false, + }); + + /// 행성 랭킹이면 `fandomId`, 사용자 랭킹이면 `uid`, 국가 랭킹이면 국가 코드. + final String id; + + final int rank; + final String name; + + /// 그 지표의 수치. **지표 카드가 띄우는 값과 같은 원천이어야 한다** — + /// 발주사 검토회신서 4절 「지표 카드와 Ranking 사이 수치·순위 불일치」. + final int value; + + /// 순위 동향. 직전 집계 대비 상승분(+)·하락분(-). + final int change; + + final bool isMine; +} + +/// 국가 코드 → 표시명(ko). 없으면 코드를 그대로 띄운다. +/// +/// **표시명을 아는 곳은 여기 한 곳뿐이다.** 서버 집계(`fnRankingCountryAggregate`)는 +/// 국가 코드만 적는다 — 표기 언어는 화면의 몫이고, 두 곳에 두면 서버에 없는 나라가 +/// 늘 때마다 배포가 필요해진다. 지원 국가 목록·표기 언어는 대조표 7-③ 확인 요청 중. +String countryName(String code) { + final name = appL10n.countryName(code); + return name == '-' ? code : name; +} + +/// 랭킹 한 판. +class FpiRanking { + const FpiRanking({required this.rows, this.mine, this.mockNote}); + + final List rows; + + /// 내 줄(행성 랭킹이면 내 행성, 사용자 랭킹이면 나). 목록 밖에 따로 띄운다 — + /// Figma `546:14628` 「소속 행성 지표 랭킹 영역」 · `546:14668` 「나의 랭킹 영역」. + final FpiRankRow? mine; + + /// 실데이터가 아닌 경우의 사유. null 이면 실연동이다. + /// 화면이 이 문구를 그대로 배지로 띄운다 — 숨기지 않는다. + final String? mockNote; + + bool get isMock => mockNote != null; +} + +/// 팬덤 라운지 글 하나. +class LoungePost { + const LoungePost({ + required this.id, + required this.authorName, + required this.createdAt, + required this.title, + required this.body, + required this.likes, + required this.comments, + this.images = const [], + this.authorPhoto, + this.videoDuration, + this.authorUid, + this.isMine = false, + this.liked = false, + }); + + final String id; + + /// 작성자 UID. 차단(`users/{uid}/blocks/{authorUid}`)의 대상이다. 표본 글은 null. + final String? authorUid; + + /// 내 글인가 — 더보기가 수정 · 삭제를 띄운다. + final bool isMine; + + /// 내가 좋아요를 눌렀는가. + final bool liked; + + /// 첫 첨부가 영상이면 그 길이(null 이면 사진). 서버 `posts` 에 아직 필드가 없어 + /// 서버 글은 늘 null 이다 — 미디어 타입·길이 필드는 후속 과제. + final Duration? videoDuration; + final String authorName; + + /// 작성자 프로필 사진. 없으면 회색 원(Figma `Ellipse 3` 기본 채움 #d9d9d9). + final String? authorPhoto; + + /// 첨부 사진. URL 또는 앱 에셋 경로. Figma `Frame 1984081008` 계열 격자. + final List images; + + /// 명세 — *리스트 생성일 · 최신순 소팅*. + final DateTime createdAt; + + final String title; + final String body; + final int likes; + final int comments; + + LoungePost copyWith({String? body, int? likes, int? comments, bool? liked}) => + LoungePost( + id: id, + authorName: authorName, + createdAt: createdAt, + title: title, + body: body ?? this.body, + likes: likes ?? this.likes, + comments: comments ?? this.comments, + images: images, + authorPhoto: authorPhoto, + videoDuration: videoDuration, + authorUid: authorUid, + isMine: isMine, + liked: liked ?? this.liked, + ); +} + +/// 라운지 댓글 하나 — 설계서 `posts/{postId}/comments/{commentId}`. +class LoungeComment { + const LoungeComment({ + required this.id, + required this.authorName, + required this.body, + required this.createdAt, + this.isMine = false, + }); + + final String id; + final String authorName; + final String body; + final DateTime createdAt; + final bool isMine; +} + +/// 라운지 글 한 편에 붙는 사진 상한 — 정책 원장 **B-44** 「이미지 5 장 10MB」. +/// 1 장 상한 · 형식은 [checkProfilePhoto] 와 같다(Storage 규칙도 같은 수). +const int loungeMaxPhotos = 5; + +/// 글 사진을 1차 검증한다. 장수 · 용량 · 형식 중 하나라도 넘으면 +/// `PhotoRejected` 를 던진다. Storage · Firestore 규칙이 같은 수를 한 번 더 본다. +void checkLoungePhotos(List photos) { + if (photos.length > loungeMaxPhotos) { + throw PhotoRejected(appL10n.loungeMaxPhotos('$loungeMaxPhotos')); + } + photos.forEach(checkProfilePhoto); +} + +/// 행성 또는 성운 하나의 상세. +class PlanetDetail { + const PlanetDetail({ + required this.fandomId, + required this.name, + this.stage, + required this.isNebula, + required this.totalFpi, + required this.scores, + required this.members, + required this.posts, + this.mockNote, + this.ownerName, + this.ownerPhoto, + this.fpiUpdatedAt, + this.joined = true, + }); + + /// 내가 이 행성/성운의 멤버인가(`fandoms/{id}/members/{uid}` 존재). 히어로의 + /// 「가입중 / 가입하기」 알약이 이 값을 그린다(명세 §2.72 「가입 버튼 - 소속 상태」). + final bool joined; + + /// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 원장 B-15). 없으면 null. + final DateTime? fpiUpdatedAt; + + /// 개설자 표시명 · 사진. `fandoms` 문서에 아직 자리가 없어 서버 상세는 null 이고, + /// null 이면 히어로가 개설자 줄을 그리지 않는다(시안 `BTS_Maker` 를 박지 않는다). + final String? ownerName; + final String? ownerPhoto; + + /// Firestore `fandoms/{fandomId}` 문서 ID. 지표별 사용자 랭킹이 이 값으로 + /// 하위 `members` 를 질의한다. + final String fandomId; + + final String name; + + /// 성운은 아직 정식 행성이 아니다. 정책 설계서 §P-08. + final bool isNebula; + final int? stage; + + /// 명세 — 실시간 데이터 기준. 산출식은 정책 설계서 §P-03(초기 MVP 단순 합산). + final int totalFpi; + + final List scores; + final int members; + final List posts; + + /// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. 화면이 그대로 배지로 + /// 띄운다 — 숨기지 않는다(대조표 3-5). + final String? mockNote; + + /// 육각형 그래프 정규화 기준. + int get maxScore => + scores.map((s) => s.value).fold(0, (a, b) => a > b ? a : b); +} + +/// 행성 상세·지표 카드·지표별 랭킹이 **모두 이 하나를 지난다.** +/// +/// 발주사 검토회신서 4절이 「지표 카드와 Ranking 상세 사이 수치·순위 불일치, +/// 서로 다른 지표 화면에 동일 Ranking 반복」을 지적했다. 원인은 랭킹 화면이 +/// 저장소를 거치지 않고 화면 안의 상수를 그렸기 때문이다. 원천을 하나로 모으면 +/// 값이 갈릴 자리가 없어진다. +class HeroDropReceipt { + const HeroDropReceipt({required this.txId, required this.amount}); + final String txId; + final int amount; +} + +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}); + final String id; + final PlanetHistoryKind kind; + final DateTime occurredAt; + final int? fromStage; + final int? toStage; + final bool participationConfirmed; + final int? amount; +} + +class PlanetHistoryPage { + const PlanetHistoryPage({required this.items, this.nextCursor}); + final List items; + final String? nextCursor; +} + +abstract interface class PlanetRepository { + Future history({String? cursor, bool personal = false}); + Future claimHeroDrop(String dropId); + Future load({required bool nebula}); + + /// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다. + Future join(); + + /// 명세 — *최초 5개, 스크롤 시 5개씩 호출*. + Future> morePosts({required int offset, int limit = 5}); + + /// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44). + Future createPost(String body, {List photos = const []}); + + /// 내 글 본문 수정. 사진은 바꾸지 않는다. + Future updatePost(LoungePost post, String body); + + Future deletePost(LoungePost post); + + /// 좋아요 켜고 끄기. 수는 서버 트리거가 다시 센다 — 돌려주는 값은 화면용 예측이다. + Future toggleLike(LoungePost post); + + Future> comments(String postId); + + Future addComment(String postId, String body); + + Future deleteComment(String postId, String commentId); + + /// 신고 접수 — `reports` 에 쌓고 Admin 이 처리한다(B-45). + Future reportPost(LoungePost post, String reason); + + /// 작성자 차단 — `users/{uid}/blocks/{authorUid}`. 이후 목록에서 빠진다. + Future blockAuthor(LoungePost post); + + /// 지표별 **행성** 랭킹 — Figma `546:14628`·`546:14639`. + /// + /// 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성 중 최근 7 일 유효 활동 + /// 존재 행성*. 서버가 `fandoms.rankingEligible` 로 표시한다. + Future planetRanking({required FpiMetric metric, int limit = 30}); + + /// 지표별 **사용자** 랭킹 — Figma `546:14668`·`546:14674`. + /// 행성 랭킹의 한 행을 눌러 들어오므로 대상 행성을 받는다. + Future userRanking({ + required FpiMetric metric, + required String fandomId, + int limit = 30, + }); + + /// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」). + Future countryRanking({required FpiMetric metric, int limit = 30}); +} + +/// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든 +/// 그 행성의 저장소를 받도록 [Repositories.planet] 을 그대로 넘겨 받는다. +typedef PlanetRepositoryFactory = PlanetRepository Function(String fandomId); + +/// 서버가 없을 때의 기본 팩토리. 상수 기본값으로 쓸 수 있게 최상위 함수로 둔다. +/// **받은 ID 를 표본에 넘긴다** — 버리면 어느 행성을 눌러도 같은 상세가 열린다. +PlanetRepository samplePlanetRepository(String fandomId) => + SamplePlanetRepository(fandomId: fandomId); + +/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** +/// +/// 표본이지만 **한 표에서 전부 파생시킨다.** 지표 카드 값·행성 랭킹 순위·사용자 +/// 랭킹이 각자 다른 상수를 들고 있으면, 서버가 붙기 전 검수에서 다시 「수치가 안 +/// 맞는다」가 된다(발주사 검토회신서 4절). +class SamplePlanetRepository implements PlanetRepository { + @override + Future join() async {} + + @override + 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; + + /// 상세를 열 행성. **내 행성과 같지 않다** — 갤럭시 HUD 는 남의 행성도 연다. + final String fandomId; + + static final _base = DateTime(2026, 8, 31, 14); + + /// 내 행성. 「내 기여도」·「내 행성 순위」의 기준이며 [fandomId] 와 다를 수 있다. + static const _myFandomId = 'bts'; + + /// 내 행성인가. 내 기여도처럼 **나에게만 있는 값**의 게이트다. + /// [fandomId] 가 아니라 [_row] 로 판정한다 — 빈 문자열도 내 행성으로 떨어지므로 + /// 「상세는 내 행성, 기여도는 없음」처럼 갈리지 않는다. + bool get _isMine => _row.$1 == _myFandomId; + + /// 상세를 그릴 행의 값. + /// + /// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서 + /// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고, + /// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다. + (String, String, List) get _row => + _planets.firstWhere((p) => p.$1 == fandomId, orElse: () => _planets.first); + + /// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인 + /// `bpm · health · referral · contribution · community`. + /// + /// **축마다 1위가 다르도록 짰다.** 발주사가 「서로 다른 지표 화면에 동일한 + /// Ranking 데이터가 반복된다」를 지적했으므로, 표본에서도 지표를 바꾸면 순서가 + /// 바뀌는 것이 눈으로 확인돼야 한다. + /// 각 행의 합계는 갤럭시 맵 표본([SampleGalaxyRepository])의 `fpi` 와 같다. + static const _planets = <(String, String, List)>[ + ('bts', 'BTS', [82400, 61200, 38900, 46710, 55100]), + ('newjeans', 'NewJeans', [91200, 44300, 52800, 42005, 41600]), + ('seventeen', 'SEVENTEEN', [58700, 78400, 31200, 22920, 62900]), + ('ive', 'IVE', [46100, 39800, 61500, 22640, 28700]), + ('aespa', 'aespa', [37400, 52600, 29100, 23350, 33800]), + ('lesserafim', 'LE SSERAFIM', [29800, 31500, 44200, 21480, 25100]), + ]; + + /// 행성별 멤버 수. 갤럭시 맵 표본([SampleGalaxyRepository])의 `members` 와 같은 + /// 값이다 — 갤럭시 HUD 와 상세가 다른 인원을 띄우면 그것부터 지적받는다. + static const _members = { + 'bts': 12840, + 'newjeans': 11230, + 'seventeen': 10450, + 'ive': 8320, + 'aespa': 7640, + 'lesserafim': 6910, + }; + + /// 내 행성 안의 사용자 표본. + static const _users = <(String, String, List)>[ + ('u-bunnies', '버니즈', [4820, 2110, 1240, 1530, 980]), + ('u-carat', '캐럿', [3960, 3480, 620, 870, 2140]), + ('u-me', '나', [2740, 1290, 3180, 4210, 1620]), + ('u-moomin', '무민', [1880, 4020, 940, 760, 3050]), + ('u-bora', '보라', [1210, 870, 2260, 2980, 1180]), + ('u-dive', '다이브짱', [940, 1640, 1720, 1120, 610]), + ]; + + static const _countries = <(String, String, List)>[ + ('KR', '대한민국', [1284000, 942000, 610000, 998000, 1105000]), + ('JP', '일본', [921000, 388000, 742000, 611000, 530000]), + ('US', '미국', [733000, 815000, 402000, 455000, 690000]), + ('ID', '인도네시아', [512000, 296000, 588000, 372000, 341000]), + ('ZZ', '미지정', [84000, 61000, 47000, 55000, 72000]), + ]; + + /// 내 기여 상위 %. 사용자 표본에서 「나」의 순위로 계산한다. + static int _myTopPercent(FpiMetric metric) { + final rows = _rank(_users, metric, myId: 'u-me', limit: _users.length); + final me = rows.mine; + if (me == null) return 100; + return (me.rank * 100 / _users.length).ceil(); + } + + /// 표를 지표 하나로 내림차순 정렬해 랭킹 한 판을 만든다. + static FpiRanking _rank( + List<(String, String, List)> table, + FpiMetric metric, { + String? myId, + required int limit, + }) { + final axis = metric.index; + final sorted = [...table]..sort((a, b) => b.$3[axis].compareTo(a.$3[axis])); + final rows = [ + for (final (i, row) in sorted.indexed) + FpiRankRow( + id: row.$1, + rank: i + 1, + name: row.$2, + value: row.$3[axis], + // 순위 동향도 지표마다 달라야 화면이 같아 보이지 않는다. + change: (axis + i) % 5 - 2, + isMine: row.$1 == myId, + ), + ]; + return FpiRanking( + rows: rows.take(limit).toList(growable: false), + mine: rows.where((r) => r.isMine).firstOrNull, + ); + } + + @override + Future load({required bool nebula}) async { + // 성운은 아직 랭킹 모집단이 아니다(별첨 B — 정식 행성 중 최근 7 일 유효 활동). + final row = _row; + final axes = nebula ? [for (final v in row.$3) (v / 60).round()] : row.$3; + return PlanetDetail( + fandomId: nebula ? 'new-${row.$1}' : row.$1, + name: nebula ? 'New_${row.$2}' : row.$2, + isNebula: nebula, + // TOTAL FPI = 5 축 단순 합산(별첨 B · 설계서 §4.1, 가중치 초기 1.0). + // 따로 든 상수가 아니라 축의 합이라 육각형·지표 카드와 어긋날 수 없다. + totalFpi: axes.fold(0, (a, b) => a + b), + members: nebula ? 8 : _members[row.$1] ?? 0, + scores: [ + for (final metric in FpiMetric.values) + FpiScore( + metric: metric, + value: axes[metric.index], + // 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다. + rank: nebula + ? 0 + : _rank(_planets, metric, myId: row.$1, limit: _planets.length) + .mine! + .rank, + trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40, + // 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면 + // 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null). + myTopPercent: _isMine ? _myTopPercent(metric) : null, + ), + ], + posts: _posts(0, 5), + ); + } + + @override + Future> morePosts({required int offset, int limit = 5}) async => + _posts(offset, limit); + + /// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다. + static final _comments = >{}; + + @override + Future createPost( + String body, { + List photos = const [], + }) async { + checkLoungePhotos(photos); + // 표본은 올릴 곳이 없어 사진을 붙이지 않는다 — 가짜 URL 을 그리면 깨진 칸이 뜬다. + return LoungePost( + id: 'new-${DateTime.now().microsecondsSinceEpoch}', + authorName: '나', + createdAt: DateTime.now(), + title: '', + body: body, + likes: 0, + comments: 0, + isMine: true, + ); + } + + @override + Future updatePost(LoungePost post, String body) async => + post.copyWith(body: body); + + @override + Future deletePost(LoungePost post) async {} + + @override + Future toggleLike(LoungePost post) async => post.copyWith( + liked: !post.liked, + likes: post.likes + (post.liked ? -1 : 1), + ); + + @override + Future> comments(String postId) async => [ + ...?_comments[postId], + ]; + + @override + Future addComment(String postId, String body) async { + final c = LoungeComment( + id: 'c${DateTime.now().microsecondsSinceEpoch}', + authorName: '나', + body: body, + createdAt: DateTime.now(), + isMine: true, + ); + (_comments[postId] ??= []).add(c); + return c; + } + + @override + Future deleteComment(String postId, String commentId) async => + _comments[postId]?.removeWhere((c) => c.id == commentId); + + @override + Future reportPost(LoungePost post, String reason) async {} + + @override + Future blockAuthor(LoungePost post) async {} + + @override + Future planetRanking({required FpiMetric metric, int limit = 30}) async => + _rank(_planets, metric, myId: _myFandomId, limit: limit); + + /// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면 + /// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가 + /// 인자로 받으므로(행성 랭킹에서 누른 행) 필드가 아니라 인자로 판정한다. + /// + /// ponytail: 행의 이름·수치는 행성이 달라도 같다. 표본을 여섯 벌 더 지어내는 + /// 것이라 서버가 붙으면 버릴 값이다. 발주사가 지적한 「같은 Ranking 반복」은 + /// 지표(metric)축에서 갈리므로 그쪽 가드(f3 회귀 테스트)로 지킨다. + @override + Future userRanking({ + required FpiMetric metric, + required String fandomId, + int limit = 30, + }) async => _rank( + _users, + metric, + myId: fandomId == _myFandomId ? 'u-me' : null, + limit: limit, + ); + + @override + Future countryRanking({required FpiMetric metric, int limit = 30}) async => + // 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713). + _rank(_countries, metric, myId: 'KR', limit: limit); + + static List _posts(int offset, int limit) => [ + for (var i = offset; i < offset + limit && i < 18; i++) + LoungePost( + id: 'p$i', + authorName: ['아미보이', '다이브짱', '네누', '버니즈', '캐럿'][i % 5], + createdAt: _base.subtract(Duration(hours: i * 3)), + title: [ + '오늘 공방 다녀왔어요', + '이번 앨범 최애 트랙 뭐예요?', + '굿즈 정리했습니다', + '콘서트 좌석 후기', + '데뷔일 프로젝트 모아요', + ][i % 5], + body: '팬덤 라운지에 올라온 글입니다. 최신순으로 다섯 개씩 불러옵니다.', + likes: 40 - i, + comments: 12 - (i % 7), + ), + ]; +} diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart new file mode 100644 index 0000000..984bf53 --- /dev/null +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -0,0 +1,1104 @@ +import 'planet_history_screen.dart'; +import 'hero_participation_sheet.dart'; +import 'package:flutter/material.dart'; + +import '../../core/data/backend.dart' show BackendException; +import '../../core/data/planet_visual_state.dart'; + +import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; +import '../eclipse/eclipse_data.dart'; +import '../eclipse/widgets/eclipse_banner.dart'; +import '../home/home_screen.dart' show formatPop; +import '../home/home_data.dart' show defaultNebulaTargets; +import 'lounge_post_screens.dart'; +import 'moderation.dart'; +import 'planet_data.dart'; +import 'fpi_ranking_screen.dart'; +import 'widgets/fpi_hexagon.dart'; +import 'widgets/planet_detail_hero.dart'; +import 'widgets/action_sheet.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 행성 상세 · 성운 상세 — 화면 대조 원장 F3. +/// +/// Figma 노드 +/// - `373:441117` Galaxy/행성 상세 · `343:213428` 홈/행성 상세_FPI +/// - `373:442708` Galaxy/성운 상세 +/// - `251:51073` 홈/행성 상세_팬덤 라운지 · `373:436556` FPI/BMP +/// - `373:436695` FPI/BMP/사용자 랭킹 — [FpiRankingScreen] +/// +/// FPI 와 팬덤 라운지가 같은 화면의 두 탭이라 하나로 둔다. Figma 가 노드를 나눠 +/// 두었으나 사용자에게는 한 화면이며, 따로 만들면 헤더가 두 벌이 된다. +class PlanetDetailScreen extends StatefulWidget { + const PlanetDetailScreen({ + super.key, + this.nebula = false, + this.watchPlanet = noPlanetWatch, + required this.repository, + required this.eclipse, + }); + + /// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §P-08). + final bool nebula; + final PlanetRepository repository; + final PlanetWatch watchPlanet; + + /// 이 행성의 이클립스 집계. 침식·방어 진행을 FPI 탭 상단에 실시간으로 띄운다. + final EclipseRepository eclipse; + + @override + State createState() => _PlanetDetailScreenState(); +} + +class _PlanetDetailScreenState extends State { + late Future _detail; + + @override + void initState() { + super.initState(); + _detail = widget.repository.load(nebula: widget.nebula); + } + + /// 「가입하기」 — 가입 뒤 상세를 다시 읽어 알약과 멤버 수를 서버 값으로 맞춘다. + Future _join() async { + try { + await widget.repository.join(); + if (mounted) { + setState(() { _detail = widget.repository.load(nebula: widget.nebula); }); + } + } on BackendException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message))); + } + } + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _detail, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + final d = snap.data; + if (d == null) { + return Scaffold(body: Center(child: Text(context.l10n.commonLoadFailed))); + } + // 시안(373:441117)은 히어로 543 아래 「FPI | 팬덤 라운지」 탭이 오고 화면 전체가 + // 한 번에 스크롤된다. 스크롤하면 히어로가 접혀 앱 바(행성 축소판 + 이름)와 탭만 + // 남는다 — 라운지 글이 보이게(발주사 지적, 2026-09-22 시연). + final top = MediaQuery.paddingOf(context).top; + return DefaultTabController( + length: 2, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + sliver: SliverAppBar( + pinned: true, + expandedHeight: + PlanetDetailHero.designHeight - top + _tabHeight, + backgroundColor: AppColors.cosmicVoid, + actions: [ + IconButton( + tooltip: context.l10n.commonMore, + onPressed: () => _showMore(context, d.name), + icon: const ImageIcon( + AppIcons.ellipsisVertical, + size: 24, + ), + ), + ], + flexibleSpace: _CollapsingHero( + detail: d, + min: top + kToolbarHeight + _tabHeight, + hero: PlanetDetailHero( + fandomId: d.fandomId, + watchPlanet: widget.watchPlanet, + stage: d.stage ?? (d.isNebula ? 0 : null), + nebula: d.isNebula, + name: d.name, + members: d.members, + totalFpi: d.totalFpi, + ownerName: d.ownerName, + ownerPhoto: d.ownerPhoto, + joined: d.joined, + onJoin: d.joined ? null : _join, + onOpenHistory: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlanetHistoryScreen( + repository: widget.repository, + fandomName: d.name, + ), + ), + ), + onHeroParticipate: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => HeroParticipationSheet( + fandomId: d.fandomId, + watch: widget.watchPlanet, + repository: widget.repository, + ), + ), + ), + ), + bottom: const _Tabs(), + ), + ), + ], + body: TabBarView( + children: [ + _FpiTab( + detail: d, + repository: widget.repository, + eclipse: widget.eclipse, + ), + _LoungeTab(detail: d, repository: widget.repository), + ], + ), + ), + ), + ); + }, + ); + + void _showMore(BuildContext context, String name) => + showActionSheet(context, [ + // 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다. + ActionSheetItem(context.l10n.planetPassportHistory, () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlanetHistoryScreen( + repository: widget.repository, + fandomName: name, + ), + ), + ); + }, destructive: false), + ActionSheetItem(context.l10n.reportTitle, () => _moderate(context, 'report', name)), + ActionSheetItem(context.l10n.commonBlockAction, () => _moderate(context, 'block', name)), + ]); + + Future _moderate( + BuildContext context, + String action, + String name, + ) async { + if (action == 'report') { + final reason = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget(authorName: name, title: context.l10n.planetNamed(name)), + ), + ), + ); + if (reason != null && context.mounted) _toast(context, context.l10n.reportReceived); + return; + } + final ok = await confirmBlock( + context, + name, + message: widget.nebula + ? context.l10n.blockNebulaConfirm + : context.l10n.blockPlanetConfirm, + ); + if (ok && context.mounted) _toast(context, context.l10n.blocked); + } +} + +/// 탭 줄 높이 — 시안 `Frame 1984081142`(375×44). +const _tabHeight = 44.0; + +/// 「FPI | 팬덤 라운지」 — 좌우 16, 칸 171.5, 선택 밑줄 2 #4bc2ae · 바닥선 1 #1a1a1a, +/// 42dot Sans 16/500 선택 #4bc2ae · 비선택 #bbbbbb. +class _Tabs extends StatelessWidget implements PreferredSizeWidget { + const _Tabs(); + + @override + Size get preferredSize => const Size.fromHeight(_tabHeight); + + @override + Widget build(BuildContext context) { + const style = TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1, + ); + return ColoredBox( + color: AppColors.cosmicVoid, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + child: TabBar( + labelColor: AppColors.cosmicTeal, + unselectedLabelColor: const Color(0xFFBBBBBB), + labelStyle: style, + unselectedLabelStyle: style, + indicatorSize: TabBarIndicatorSize.tab, + indicator: const UnderlineTabIndicator( + borderSide: BorderSide(color: AppColors.cosmicTeal, width: 2), + ), + dividerColor: const Color(0xFF1A1A1A), + dividerHeight: 1, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab(height: _tabHeight, text: 'FPI'), + Tab(height: _tabHeight, text: context.l10n.loungeTitle), + ], + ), + ), + ); + } +} + +/// 히어로가 접히는 자리. 펼치면 시안 그대로, 접히면 히어로가 흐려지고 앱 바에 +/// 행성 축소판과 이름이 남는다. +class _CollapsingHero extends StatelessWidget { + const _CollapsingHero({ + required this.detail, + required this.min, + required this.hero, + }); + + final PlanetDetail detail; + final double min; + final Widget hero; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, box) { + const max = PlanetDetailHero.designHeight + _tabHeight; + final t = ((box.maxHeight - min) / (max - min)).clamp(0.0, 1.0); + final top = MediaQuery.paddingOf(context).top; + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + // 히어로는 늘 543 높이로 그리고 위로 밀어 올린다(잘리는 쪽은 위). + Positioned( + left: 0, + right: 0, + bottom: _tabHeight, + height: PlanetDetailHero.designHeight, + child: Opacity(opacity: t, child: hero), + ), + if (t < 0.4) + Positioned( + top: top, + left: 56, + right: 56, + height: kToolbarHeight, + child: Opacity( + opacity: (1 - t / 0.4).clamp(0.0, 1.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/figma/planet-detail-hero.png', + width: 28, + height: 28, + ), + const SizedBox(width: AppSpace.s2), + Flexible( + child: Text( + detail.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 18, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); +} + +/// 탭 본문 공통 — 접힌 앱 바 아래로 들어가지 않게 겹침을 받아 둔다. +Widget _tabScroll(String key, List slivers) => Builder( + builder: (context) => CustomScrollView( + key: PageStorageKey(key), + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + ), + ...slivers, + ], + ), +); + +/// 「마지막 갱신」 시각. 서버 집계 시각(`fpiUpdatedAt`)이 없으면 「--:--」. +String _hhmm(DateTime? at) => at == null + ? '--:--' + : '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}'; + +/// 지표 카드 수치 — 시안 「34.2K」 · 「1.18M」 · 「450.1K」. +String _short(int v) => v >= 1000000 + ? '${(v / 1000000).toStringAsFixed(2)}M' + : v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}K' + : '$v'; + +/// FPI 탭 — TOTAL FPI 영역 + 상세 리스트. 시안 `Frame 1984081160`(좌우 20 · 위아래 16). +class _FpiTab extends StatelessWidget { + const _FpiTab({ + required this.detail, + required this.repository, + required this.eclipse, + }); + + final PlanetDetail detail; + + /// 이클립스 집계 구독. 대상은 이 행성이다. + final EclipseRepository eclipse; + + /// 지표 카드와 랭킹이 **같은 저장소**를 지나야 수치가 갈리지 않는다. + /// 랭킹 화면이 제 안의 상수를 그리던 것이 발주사 검토회신서 4절의 지적이다. + final PlanetRepository repository; + + @override + Widget build(BuildContext context) { + // 지표 카드 → 행성 지표 랭킹(Figma 546:14628·14639) → 사용자 지표 랭킹. + void open(FpiScore s) => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + FpiRankingScreen(metric: s.metric, repository: repository), + ), + ); + if (detail.isNebula) { + return _tabScroll('fpi', [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList.list( + children: [ + if (detail.mockNote case final note?) + Padding( + padding: const EdgeInsets.only(bottom: AppSpace.s3), + child: MockBadge(note: note), + ), + const _NebulaFpi(), + ], + ), + ), + ]); + } + final scores = detail.scores; + return _tabScroll('fpi', [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + sliver: SliverList.list( + children: [ + // 실데이터가 아닌 자리가 남아 있으면 숨기지 않고 그대로 알린다(대조표 3-5). + if (detail.mockNote case final note?) + Padding( + padding: const EdgeInsets.only(bottom: AppSpace.s3), + child: MockBadge(note: note), + ), + // 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다. + // TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다 + // (원장 B-55 — FPI 감소는 Eclipse 가 담당한다). + EclipseBanner( + repository: eclipse, + fandomId: detail.fandomId, + margin: const EdgeInsets.only(bottom: AppSpace.s3), + ), + Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFF19191C), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + 'TOTAL FPI : ${formatPop(detail.totalFpi)}', + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 22, + height: 1, + color: AppColors.onCosmic, + ), + ), + // 정책 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출. 시안 밖 문구라 + // 사본 간격 20(TOTAL FPI ↔ 육각형) 안에 4 + 10 + 6 으로 넣는다. + const SizedBox(height: 4), + Text( + context.l10n.planetLastUpdated(_hhmm(detail.fpiUpdatedAt)), + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 10, + fontWeight: FontWeight.w500, + height: 1, + color: AppColors.onCosmicMuted, + ), + ), + const SizedBox(height: 6), + FpiHexagon(scores: scores, max: detail.maxScore), + ], + ), + ), + for (var i = 0; i < scores.length; i += 2) ...[ + const SizedBox(height: 8), + if (i + 1 < scores.length) + Row( + children: [ + Expanded( + child: _ScoreCard( + score: scores[i], + onTap: () => open(scores[i]), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ScoreCard( + score: scores[i + 1], + onTap: () => open(scores[i + 1]), + ), + ), + ], + ) + else + // 홀수 마지막 카드는 한 줄을 다 쓴다(시안 COMMUNITY 335). + _ScoreCard(score: scores[i], onTap: () => open(scores[i])), + ], + ], + ), + ), + ]); + } +} + +/// 성운 FPI 탭 — 시안 `373:442708` `Frame 1984081160`(안쪽 16 · 간격 16). +/// +/// 「기여 현황」 카드(#1a1a1c r12 · 안쪽 12/16) — 성운 성장률 · 진행 막대 · 0 ~ 목표, +/// 「실시간 기여 랭킹」 — 1위 왕관 · 빈 원 80 · 「아직 기여자가 없어요」. +/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 성운 누적 POP 과 기여자 +/// 목록은 상세 데이터에 아직 없어 0 · 빈 상태로 둔다. +class _NebulaFpi extends StatelessWidget { + const _NebulaFpi(); + + static TextStyle _b(double size, Color color, {double? height}) => TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: FontWeight.w600, + height: height, + color: color, + ); + + static TextStyle _d(double size, Color color, {double height = 1}) => + TextStyle( + fontFamily: AppFonts.display, + fontSize: size, + fontWeight: FontWeight.w400, + height: height, + letterSpacing: size * -0.025, + color: color, + ); + + @override + Widget build(BuildContext context) { + const pop = 0; + final target = defaultNebulaTargets.pop; + final rate = pop * 100 ~/ target; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(context.l10n.planetContribution, style: _b(18, AppColors.onCosmic)), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1C), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.planetNebulaGrowth, + style: _b( + 12, + const Color(0xFFA3A2A0), + height: 20 / 12, + ).copyWith(fontWeight: FontWeight.w500), + ), + Text.rich( + TextSpan( + text: '$rate', + style: _d(32, AppColors.onCosmic, height: 1.4), + children: [ + TextSpan( + text: '%', + style: _d(20, AppColors.onCosmic, height: 1.4), + ), + ], + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: pop / target, + minHeight: 10, + backgroundColor: Colors.black.withValues(alpha: 0.3), + color: AppColors.cosmicTeal, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('0', style: _d(12, const Color(0xFFCDCDCD))), + Text( + formatPop(target), + style: _d(12, const Color(0xFFCDCDCD)), + ), + ], + ), + ], + ), + ), + 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), + ], + ), + ], + ); + } +} + +/// 지표 카드 — 시안 `Frame 1984081152`(#19191c r16 · 안쪽 12 · 높이 117). +/// +/// 명세 *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*. 라벨 12 #cdcdcd, 값 ABeeZee 18, +/// 「전체 n위 ▲n」(상승 #f28180 · 하락 #6866f3 · 변동 없음 #cdcdcd 「-」), 오른쪽 위 +/// 3D 아이콘 54, 아래 #101010 알약에 「내 기여도 · 상위 n%」. +class _ScoreCard extends StatelessWidget { + const _ScoreCard({required this.score, required this.onTap}); + + final FpiScore score; + final VoidCallback onTap; + + static String _icon(FpiMetric m) => switch (m) { + FpiMetric.bpm => 'assets/figma/icon-bpm.png', + FpiMetric.health => 'assets/figma/icon-health.png', + FpiMetric.referral => 'assets/figma/icon-referral.png', + FpiMetric.community => 'assets/figma/icon-community.png', + FpiMetric.contribution => 'assets/figma/icon-contribution.png', + }; + + static TextStyle _t(double size, Color color, {double height = 1.4}) => + TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: FontWeight.w500, + height: height, + letterSpacing: size * -0.025, + color: color, + ); + + @override + Widget build(BuildContext context) { + final trend = score.trend; + final color = trend > 0 + ? const Color(0xFFF28180) + : trend < 0 + ? AppColors.neonViolet + : const Color(0xFFCDCDCD); + final icon = _icon(score.metric); + return Material( + color: const Color(0xFF19191C), + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 59, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + score.metric.label, + // 시안도 CONTRIBUTION 이 아이콘 칸에 1.5 걸친다 — 자르지 않는다. + softWrap: false, + overflow: TextOverflow.visible, + style: _t(12, const Color(0xFFCDCDCD)), + ), + Text( + _short(score.value), + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 18, + height: 1.4, + letterSpacing: -0.45, + color: AppColors.onCosmic, + ), + ), + Row( + children: [ + Text( + context.l10n.planetOverallRank('${score.rank}'), + style: _t(12, color), + ), + const SizedBox(width: 4), + if (trend == 0) + Text( + '-', + style: _t( + 10, + const Color(0xFF777777), + height: 1, + ), + ) + else ...[ + Text( + trend > 0 ? '▲' : '▼', + style: _t(8, color, height: 1), + ), + const SizedBox(width: 2), + Text( + formatPop(trend.abs()), + style: _t(10, color, height: 1), + ), + ], + ], + ), + ], + ), + ), + SizedBox( + width: 54, + height: 54, + // 시안 3D 아이콘 54. 번개(CONTRIBUTION)만 36 원형이라 안쪽 여백 9. + child: score.metric == FpiMetric.contribution + ? Padding( + padding: const EdgeInsets.all(9), + child: Image.asset(icon), + ) + : Image.asset(icon, fit: BoxFit.contain), + ), + ], + ), + ), + const SizedBox(height: 12), + Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: AppColors.cosmicSurface, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Text(context.l10n.planetMyContribution, style: _t(10, AppColors.onCosmic, height: 1)), + const Spacer(), + Text( + // 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는 + // 최하위라는 사실 주장이 되어 버린다(대조표 3-5). + score.myTopPercent == null + ? context.l10n.planetTallying + : context.l10n.planetTopPercent('${score.myTopPercent}'), + style: _t(10, AppColors.onCosmic, height: 1), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 팬덤 라운지 탭 — 명세 *최신순 · 최초 5개, 스크롤 시 5개씩 호출*. +class _LoungeTab extends StatefulWidget { + const _LoungeTab({required this.detail, required this.repository}); + + final PlanetDetail detail; + final PlanetRepository repository; + + @override + State<_LoungeTab> createState() => _LoungeTabState(); +} + +class _LoungeTabState extends State<_LoungeTab> { + late List _posts = [...widget.detail.posts]; + bool _loading = false; + bool _end = false; + + /// 이 화면에서 숨긴 작성자 — 「이 회원의 글 모두 숨기기」. + final _hidden = {}; + + Future _more() async { + if (_loading || _end) return; + setState(() => _loading = true); + final next = await widget.repository.morePosts(offset: _posts.length); + if (!mounted) return; + setState(() { + _posts = [..._posts, ...next]; + _loading = false; + _end = next.isEmpty; + }); + } + + @override + Widget build(BuildContext context) { + final posts = [ + for (final p in _posts) + if (!_hidden.contains(p.authorName)) p, + ]; + return Scaffold( + // 바깥 NestedScrollView 가 스크롤을 쥐므로 컨트롤러 대신 알림으로 끝을 본다. + body: NotificationListener( + onNotification: (n) { + if (n.metrics.extentAfter < 240) _more(); + return false; + }, + child: _tabScroll('lounge', [ + SliverPadding( + padding: const EdgeInsets.all(AppSpace.s4), + sliver: SliverList.separated( + // 로딩 표시는 **실제로 불러오는 동안에만** 둔다. 「아직 끝이 아니다」를 + // 기준으로 두면 사용자가 스크롤하기 전까지 바퀴가 영원히 돈다. + itemCount: posts.length + (_loading ? 1 : 0), + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, i) { + if (i >= posts.length) { + return const Padding( + padding: EdgeInsets.all(AppSpace.s4), + child: Center(child: CircularProgressIndicator()), + ); + } + return _PostRow( + post: posts[i], + repository: widget.repository, + onChanged: _replace, + onMore: () => _postMore(posts[i]), + ); + }, + ), + ), + ]), + ), + // 시안 `Frame 1984081111` — 52 원 #ff7ddd · plus 24. + floatingActionButton: SizedBox.square( + dimension: 52, + child: FloatingActionButton( + onPressed: _write, + tooltip: context.l10n.postWrite, + elevation: 0, + shape: const CircleBorder(), + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmic, + child: const ImageIcon(AppIcons.plus, size: 24), + ), + ), + ); + } + + /// 글 한 편을 바꾸거나(`post`) 뺀다(`null`). 상세 화면이 돌려준 결과를 받는다. + void _replace(LoungePost old, LoungePost? post) => setState(() { + _posts = [ + for (final p in _posts) + if (p.id != old.id) + p + else if (post != null) + post, + ]; + }); + + Future _run(Future Function() action) async { + try { + await action(); + } catch (e) { + if (mounted) _toast(context, loungeError(e)); + } + } + + /// 글 더보기 — 시안 `251:51073` 하단 시트(신고하기 · 이 회원의 글 모두 숨기기 · 취소). + /// 내 글이면 수정 · 삭제를 띄운다. + 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), + ), + ), + ); + if (updated != null && mounted) _replace(post, updated); + }), + ActionSheetItem(context.l10n.commonDeleteAction, () async { + if (!await confirmOverlay(context, context.l10n.postDeleteConfirm)) return; + await _run(() async { + await widget.repository.deletePost(post); + _replace(post, null); + }); + }), + ] else ...[ + ActionSheetItem(context.l10n.reportTitle, () async { + final reason = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget( + authorName: post.authorName, + title: loungePostText(post), + ), + ), + ), + ); + if (reason == null) return; + await _run(() async { + await widget.repository.reportPost(post, reason); + if (mounted) _toast(context, context.l10n.reportReceived); + }); + }), + ActionSheetItem(context.l10n.planetHideMember, () async { + if (!await confirmBlock(context, post.authorName)) return; + // 차단은 서버(`users/{uid}/blocks`)에 남아 다음에 열어도 빠진다. + await _run(() async { + await widget.repository.blockAuthor(post); + if (mounted) setState(() => _hidden.add(post.authorName)); + }); + }), + ], + ]); + + Future _write() async { + final post = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => LoungeComposeScreen( + onSubmit: (d) => + widget.repository.createPost(d.body, photos: d.photos), + ), + ), + ); + if (post == null || !mounted) return; + setState(() => _posts = [post, ..._posts]); + } +} + +/// 라운지 글 카드 — 시안 `Frame 1984081004`(#19191c r8 · 안쪽 16 · 간격 16). +class _PostRow extends StatelessWidget { + const _PostRow({ + required this.post, + required this.repository, + required this.onChanged, + required this.onMore, + }); + + final LoungePost post; + final PlanetRepository repository; + final void Function(LoungePost old, LoungePost? post) onChanged; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) => Material( + color: const Color(0xFF19191C), + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => LoungePostScreen( + post: post, + repository: repository, + onChanged: (p) => onChanged(post, p), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LoungeAuthorRow( + post: post, + trailing: InkResponse( + onTap: onMore, + radius: 18, + child: Image.asset( + 'assets/figma/icon-post-more.png', + width: 24, + height: 24, + semanticLabel: context.l10n.commonMore, + ), + ), + ), + const SizedBox(height: 12), + Text( + loungePostText(post), + style: loungeText(14, AppColors.onCosmic), + ), + if (post.images.isNotEmpty) ...[ + const SizedBox(height: 16), + _PostImages(images: post.images), + ], + const SizedBox(height: 16), + LoungeCounts(post: post, icon: 14, color: const Color(0xFF4F4F4F)), + ], + ), + ), + ), + ); +} + +/// 첨부 격자 — 1장은 정사각, 3장은 큰 칸 + 오른쪽 두 칸, 그 밖은 2열(넘치면 「+n」). +/// +/// ponytail: 시안의 6장 이상 배치(3+3, 마지막 칸 「+n」)는 2×2 + 「+n」으로 줄였다. +/// 여섯 장 이상 글이 흔해지면 시안 배치로 늘린다. +class _PostImages extends StatelessWidget { + const _PostImages({required this.images}); + + final List images; + + Widget _tile(String src, {int more = 0}) => AspectRatio( + aspectRatio: 1, + child: Stack( + fit: StackFit.expand, + children: [ + Image(image: loungeImage(src), fit: BoxFit.cover), + if (more > 0) + ColoredBox( + color: Colors.black.withValues(alpha: 0.7), + child: Center( + child: Text( + '+$more', + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w900, + color: AppColors.onCosmic, + ), + ), + ), + ), + ], + ), + ); + + @override + Widget build(BuildContext context) { + if (images.length == 1) return _tile(images.first); + if (images.length == 3) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 2, child: _tile(images[0])), + const SizedBox(width: 6), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [_tile(images[1]), _tile(images[2])], + ), + ), + ], + ), + ); + } + final shown = images.take(4).toList(); + Widget row(int from) => Row( + children: [ + for (var i = from; i < from + 2; i++) ...[ + if (i > from) const SizedBox(width: 6), + Expanded( + child: i < shown.length + ? _tile(shown[i], more: i == 3 ? images.length - 4 : 0) + : const SizedBox.shrink(), + ), + ], + ], + ); + return Column( + children: [ + row(0), + if (shown.length > 2) ...[const SizedBox(height: 4), row(2)], + ], + ); + } +} + +void _toast(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); +} diff --git a/app/lib/features/planet/planet_history_screen.dart b/app/lib/features/planet/planet_history_screen.dart new file mode 100644 index 0000000..8308b42 --- /dev/null +++ b/app/lib/features/planet/planet_history_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'planet_data.dart'; +import '../../core/i18n/app_locale.dart'; + +class PlanetHistoryScreen extends StatefulWidget { + const PlanetHistoryScreen({ + super.key, + required this.repository, + required this.fandomName, + }); + final PlanetRepository repository; + final String fandomName; + @override + State createState() => _PlanetHistoryScreenState(); +} + +class _PlanetHistoryScreenState extends State { + final _items = []; + String? _cursor; + bool _loading = false; + bool _loaded = false; + bool _failed = false; + bool _personal = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + if (_loading) return; + setState(() { + _loading = true; + _failed = false; + }); + try { + final page = await widget.repository.history(cursor: _cursor, personal: _personal); + if (!mounted) return; + setState(() { + final ids = _items.map((item) => item.id).toSet(); + _items.addAll(page.items.where((item) => ids.add(item.id))); + _cursor = page.nextCursor; + _loaded = true; + }); + } catch (_) { + if (mounted) setState(() => _failed = true); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(context.l10n.historyTitle)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(widget.fandomName, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + SegmentedButton( + segments: [ + 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(); + }, + ), + const SizedBox(height: 16), + for (final item in _items) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(switch (item.kind) { + PlanetHistoryKind.evolution => Icons.auto_awesome, + PlanetHistoryKind.hibernation => Icons.bedtime_outlined, + PlanetHistoryKind.wakeUp => Icons.wb_sunny_outlined, + PlanetHistoryKind.zap => Icons.bolt, + PlanetHistoryKind.questReward => Icons.task_alt, + PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline, + PlanetHistoryKind.funding => Icons.favorite_border, + }), + title: Text(switch (item.kind) { + PlanetHistoryKind.evolution => + context.l10n.historyEvolution( + '${item.fromStage}', + '${item.toStage}', + ), + PlanetHistoryKind.hibernation => context.l10n.historyHibernation, + PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp, + PlanetHistoryKind.zap => context.l10n.historyZap, + PlanetHistoryKind.questReward => context.l10n.historyQuest, + PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus, + PlanetHistoryKind.funding => context.l10n.historyFunding, + }), + subtitle: Text( + [ + MaterialLocalizations.of( + context, + ).formatMediumDate(item.occurredAt.toLocal()), + if (item.amount != null) context.l10n.historyGrowthPop('${item.amount}'), + if (item.participationConfirmed) context.l10n.historyJoined, + ].join('\n'), + ), + ), + if (_loading) const Center(child: CircularProgressIndicator()), + if (_failed) ...[ + Text(context.l10n.historyLoadFailed), + TextButton(onPressed: _load, child: Text(context.l10n.commonRetry)), + ] else if (_loaded && _items.isEmpty && _cursor == null) + Text(_personal + ? context.l10n.historyMineEmpty + : 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 new file mode 100644 index 0000000..e8b336f --- /dev/null +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -0,0 +1,665 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_storage/firebase_storage.dart'; + +import '../../core/data/backend.dart'; +import '../my/my_data.dart' show PickedPhoto, PhotoRejected; +import '../my/my_repository_firestore.dart'; +import 'moderation.dart' show reportReasonCodes; +import 'planet_data.dart'; +import '../../core/i18n/app_locale.dart'; + +/// 행성·성운 상세의 실 서버 구현 — 화면 대조 원장 F3. +/// +/// **지표 카드·행성 랭킹·사용자 랭킹이 한 원천을 본다.** 발주사 검토회신서 4절이 +/// 지적한 「지표 카드와 Ranking 사이 수치·순위 불일치」의 원인이 화면마다 다른 +/// 데이터를 들고 있던 것이라, 값이 갈릴 자리를 없앤다. +/// +/// | 화면 | 읽는 곳 | +/// |---|---| +/// | TOTAL FPI | `fandoms/{id}.fpiScore` — 서버 가중합 하나 | +/// | 지표 카드 값·전체 순위 | `fandoms/{id}.fpiAxes[축]` · `.fpiRanks[축]` | +/// | 행성 지표 랭킹 | 같은 `fpiAxes[축]` 내림차순 · 순위는 **서버의 `fpiRanks[축]`** | +/// | 사용자 지표 랭킹 | `fandoms/{id}/members/*.fpiAxes[축]` 내림차순 | +/// | 국가 지표 랭킹 | `rankings/country-{축}` 사전 집계 1건 | +/// +/// **라운지 목록은 offset 이 아니라 커서로 넘긴다.** Firestore 에는 offset 이 없고, +/// 흉내 내려면 앞 페이지를 매번 다시 읽어야 한다. 인터페이스는 명세대로 +/// `offset` 을 받되 내부에서는 마지막 문서를 커서로 들고 간다. +class FirestorePlanetRepository implements PlanetRepository { + FirestorePlanetRepository(this._backend, {required this.fandomId}); + + final Backend _backend; + final String fandomId; + + @override + Future history({String? cursor, bool personal = false}) async { + _backend.requireUid; + final result = await _backend.call('fnPlanetHistory', { + 'fandomId': fandomId, if (cursor != null) 'cursor': cursor, + if (personal) 'scope': 'personal', + }); + final rows = result['items']; + final next = result['nextCursor']; + if (rows is! List || (next != null && (next is! String || next.isEmpty))) { + throw StateError('역사 기록을 확인할 수 없습니다.'); + } + final items = []; + for (final row in rows) { + if (row is! Map) throw StateError('역사 기록을 확인할 수 없습니다.'); + final kind = switch (row['kind']) { + 'evolution' => PlanetHistoryKind.evolution, + 'hibernation' => PlanetHistoryKind.hibernation, + 'wake_up' => PlanetHistoryKind.wakeUp, + 'zap' => PlanetHistoryKind.zap, + 'quest_reward' => PlanetHistoryKind.questReward, + 'company_ad_bonus' => PlanetHistoryKind.companyAdBonus, + 'funding' => PlanetHistoryKind.funding, + _ => 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 || + (kind == PlanetHistoryKind.evolution && + (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, + )); + } + return PlanetHistoryPage(items: items, nextCursor: next as String?); + } + + @override + Future claimHeroDrop(String dropId) async { + _backend.requireUid; + final result = await _backend.call('fnHeroDropClaim', { + 'fandomId': fandomId, 'dropId': dropId, + }); + final amount = result['claimedPop']; + final txId = result['txId']; + if (amount is! int || amount <= 0 || txId is! String || txId.isEmpty) { + throw StateError('지급 결과를 확인할 수 없습니다.'); + } + return HeroDropReceipt(txId: txId, amount: amount); + } + + /// 명세 — *최초 5개, 스크롤 시 5개씩 호출*. + static const int pageSize = 5; + + DocumentSnapshot>? _cursor; + + /// 집계 축 키. 지표 이름을 그대로 쓴다 — 설계서 §4.1 의 `fpiAxes` 키와 같다. + /// 두 벌을 두면 집계 축과 화면 표기가 갈린다. + static String _axisKey(FpiMetric metric) => metric.name; + + @override + Future join() => _backend.call('fnFandomJoin', {'fandomId': fandomId}); + + @override + Future load({required bool nebula}) async { + final db = _backend.db; + final uid = _backend.requireUid; + + final fandomF = db.doc('fandoms/$fandomId').get(); + // 최근 동향(최근 변동)은 **일 1 회 Daily FPI Snapshot 대비 현재값**이다. + // 별첨 B — 원천 이벤트 즉시 적재 · FPI/랭킹 1 분 주기 집계 · Daily FPI + // Snapshot 은 일 1 회. 스냅샷 두 건을 비교하면 「어제 대 그저께」가 되어 + // 1 분마다 움직이는 지표 카드와 어긋난다. + final snapshotF = db + .collection('fandoms/$fandomId/fpiSnapshots') + .orderBy('createdAt', descending: true) + .limit(1) + .get(); + final memberF = db.doc('fandoms/$fandomId/members/$uid').get(); + _cursor = null; + final postsF = _page(pageSize); + + final fandom = (await fandomF).data() ?? const {}; + final snapshots = (await snapshotF).docs; + final memberSnap = await memberF; + final member = memberSnap.data() ?? const {}; + final posts = await postsF; + + final axes = _map(fandom, 'fpiAxes'); + final ranks = _map(fandom, 'fpiRanks'); + final myTop = _map(member, 'topPercentByAxis'); + // MOCK: `topPercentByAxis` 는 서버 산출값인데 이를 쓰는 집계 함수가 아직 + // 없습니다. 필드는 실연동으로 읽고, **값이 없으면 null** 로 둡니다 — + // 100%(최하위)로 채우면 없는 값이 사실처럼 보입니다. 대조표 3-5 참조. + int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num + ? myTop.intOr(_axisKey(metric)) + : null; + // 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어 + // 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다. + final snapshot = snapshots.isEmpty + ? const {} + : snapshots.first.data(); + final dailyAxes = _map(snapshot, 'fpiAxes'); + final daily = dailyAxes.isNotEmpty ? dailyAxes : _map(snapshot, 'axes'); + + final stage = switch (fandom['stage']) { + int n when n >= 0 && n <= 6 => n, + _ => null, + }; + return PlanetDetail( + joined: memberSnap.exists, + stage: stage, + fandomId: fandomId, + name: fandom.strOr('name', fandomId), + // TOTAL FPI = 서버가 적어 준 `fpiScore` 하나. 클라이언트가 5축을 다시 + // 더하지 않는다 — `/config/policy` 의 가중치가 1.0 을 벗어나는 순간 + // 갤럭시 HUD(서버 `fpiScore`)와 이 화면(클라이언트 합)이 갈린다. + // 가중치 초기값은 1.0 이라 지금은 두 값이 같지만, 같아 보이는 것과 + // 한 원천을 읽는 것은 다르다(별첨 B · 설계서 §4.1). + totalFpi: fandom.intOr('fpiScore'), + fpiUpdatedAt: switch (fandom['fpiUpdatedAt']) { + Timestamp t => t.toDate(), + _ => null, + }, + isNebula: stage == null ? nebula : stage == 0, + members: fandom.intOr('memberCount'), + scores: [ + for (final metric in FpiMetric.values) + FpiScore( + metric: metric, + value: axes.intOr(_axisKey(metric)), + rank: ranks.intOr(_axisKey(metric), 0), + // 스냅샷이 아직 없으면 0 — 「오늘 시작 이후 변동 없음」이다. + trend: daily.isEmpty + ? 0 + : axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)), + // 없으면 null — 화면이 숫자 대신 「집계 준비 중」을 띄운다. + myTopPercent: myTopOf(metric), + ), + ], + posts: posts, + // 한 축이라도 서버 값이 없으면 배지로 알린다. 화면이 이 문구를 그대로 + // 띄운다 — 「집계 준비 중」 카드만으로는 이유가 보이지 않는다(대조표 3-5). + mockNote: FpiMetric.values.any((m) => myTopOf(m) == null) + ? '「내 기여 상위 %」 집계 함수가 아직 없습니다' + : null, + ); + } + + @override + Future planetRanking({ + required FpiMetric metric, + int limit = 30, + }) async { + final axis = _axisKey(metric); + // 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」. + // 최근 7 일 유효 활동 판정은 서버(1 분 집계)가 `rankingEligible` 로 적는다. + // 클라이언트가 `lastActiveAt` 부등호로 거르면 정렬 키와 부등호 필드가 달라 + // Firestore 가 질의를 거부한다. + final snap = await _backend.db + .collection('fandoms') + .where('rankingEligible', isEqualTo: true) + .orderBy('fpiAxes.$axis', descending: true) + .limit(limit) + .get(); + + // **순위는 서버가 적어 준 `fpiRanks[축]` 이다.** 쿼리 결과의 인덱스로 다시 + // 매기면 동점에서 두 계산이 갈린다 — 서버는 축 값 내림차순으로, Firestore 는 + // 같은 값끼리 문서 ID 순으로 세우기 때문이다. 그 자리가 지표 카드의 + // 「전체 n위」와 이 목록의 순위가 어긋나던 곳이다(검토회신서 4절). + // 서버 값이 아직 없는 문서만 쿼리 인덱스로 메운다. + // 서버 값이 없을 때의 메움도 1·1·3 공동 순위다(C-19). 실값(반올림 전)으로 비교하고 + // 동점 내부는 fandomId 오름차순 — Firestore 의 내림차순 정렬은 동점을 ID + // 내림차순으로 세우므로 여기서 다시 세운다. + final rows = [ + for (final (doc, rank) in rankWithTies( + snap.docs, + (d) => _raw(d.data(), axis), + (d) => d.id, + )) + _planetRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank), + ]; + + final mine = rows.where((r) => r.isMine).firstOrNull; + if (mine != null) return FpiRanking(rows: rows, mine: mine); + + // 상위 [limit] 밖이면 내 행성 문서를 한 번 더 읽어 「소속 행성 지표 랭킹 + // 영역」(Figma `546:14628`)을 채운다. 사용자 랭킹의 count() 폴백과 같은 + // 취지다 — 내 행성이 몇 등이든 그 영역은 떠야 한다. + final me = (await _backend.db.doc('fandoms/$fandomId').get()).data(); + if (me == null) return FpiRanking(rows: rows); + return FpiRanking( + rows: rows, + // 서버 순위가 없으면 목록 밖이라는 것만 아는 셈이라 0 으로 둔다. + mine: _planetRow(me, id: fandomId, axis: axis, fallbackRank: 0), + ); + } + + /// 축의 실제 산정값(반올림 전). 순위 비교에만 쓴다 — 표시는 [SnapshotReader.intOr]. + static num _raw(Map data, String axis) { + final v = _map(data, 'fpiAxes')[axis]; + return v is num ? v : 0; + } + + /// 행성 랭킹 한 줄. 목록 안이든 밖이든 **같은 필드에서** 만든다. + FpiRankRow _planetRow( + Map data, { + required String id, + required String axis, + required int fallbackRank, + }) { + final rank = _map(data, 'fpiRanks').intOr(axis, fallbackRank); + return FpiRankRow( + id: id, + rank: rank, + name: data.strOr('name', id), + value: _map(data, 'fpiAxes').intOr(axis), + // 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다. + change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank, + isMine: id == fandomId, + ); + } + + @override + Future userRanking({ + required FpiMetric metric, + required String fandomId, + int limit = 30, + }) async { + 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(); + + // 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순. + final rows = [ + for (final (doc, rank) in rankWithTies( + snap.docs, + (d) => _raw(d.data(), axis), + (d) => d.id, + )) + FpiRankRow( + id: doc.id, + rank: rank, + // UID·이메일을 이름 자리에 내보내지 않는다(G-07). + name: publicName(doc.data()['nickname']), + value: _map(doc.data(), 'fpiAxes').intOr(axis), + // MOCK: 멤버의 직전 축별 순위(`rankPrevByAxis`)를 쓰는 서버 집계가 + // 아직 없습니다. 필드가 없으면 현재 순위를 그대로 넣어 동향이 0 이 되고, + // 화면은 ▲▼ 를 그리지 않습니다. 대조표 3-5 참조. + change: _map(doc.data(), 'rankPrevByAxis').intOr(axis, rank) - rank, + isMine: doc.id == uid, + ), + ]; + + // 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로 + // 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다. + final note = snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num) + ? null + : '사용자 순위 동향은 아직 집계 전입니다'; + + final mine = rows.where((r) => r.isMine).firstOrNull; + if (mine != null) { + return FpiRanking(rows: rows, mine: mine, mockNote: note); + } + + // 상위 목록 밖이면 내 값보다 큰 멤버 수를 세어 정확한 순위를 낸다. + // Figma `546:14668` 「나의 랭킹 영역」은 내가 몇 등이든 떠야 한다. + final me = (await members.doc(uid).get()).data(); + if (me == null) return FpiRanking(rows: rows, mockNote: note); + final myValue = _map(me, 'fpiAxes').intOr(axis); + // 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19). 반올림한 값으로 세면 동률이 생긴다. + final above = await members + .where('fpiAxes.$axis', isGreaterThan: _raw(me, axis)) + .count() + .get(); + return FpiRanking( + rows: rows, + mockNote: note, + mine: FpiRankRow( + id: uid, + rank: (above.count ?? rows.length) + 1, + name: publicName(me['nickname'], appL10n.commonMe), + value: myValue, + isMine: true, + ), + ); + } + + @override + Future countryRanking({ + required FpiMetric metric, + int limit = 30, + }) async { + // 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」. + // 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다. + 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: '국가별 집계가 아직 없습니다', + ); + } + // 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다). + // 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을 + // 찾아 올린다. + 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, + )); + } + return FpiRanking( + rows: list, + mine: list.where((r) => r.isMine).firstOrNull, + ); + } + + @override + Future> morePosts({ + required int offset, + int limit = pageSize, + }) { + // offset 0 은 「처음부터 다시」라는 뜻이다. 커서를 버리고 첫 장을 읽는다. + if (offset == 0) _cursor = null; + return _page(limit); + } + + /// 한 장을 읽되 **차단한 작성자의 글은 뺀다.** 한 장이 통째로 빠지면 화면이 + /// 「끝」으로 오인하므로 서버에 글이 남아 있는 동안 다음 장을 더 읽는다. + Future> _page(int limit) async { + final blocked = await _blockedUids(); + final out = >>[]; + while (out.length < limit) { + final snap = await _pageQuery(limit: limit).get(); + if (snap.docs.isNotEmpty) _cursor = snap.docs.last; + out.addAll( + snap.docs.where((d) => !blocked.contains(d.data()['authorUid'])), + ); + if (snap.docs.length < limit) break; + } + return Future.wait(out.map(_post)); + } + + Query> _pageQuery({int limit = pageSize}) { + var query = _backend.db + .collection('posts') + .where('fandomId', isEqualTo: fandomId) + // 설계서 §2.6 — `visibility` 는 visible | hidden | deleted. 규칙이 새 글을 + // `visible` 로만 만들게 하므로 목록도 같은 값을 본다. + .where('visibility', isEqualTo: 'visible') + // 명세 — 최신순 소팅. 인덱스는 firestore.indexes.json 의 + // (fandomId, visibility, createdAt desc) 가 받는다. + .orderBy('createdAt', descending: true); + final cursor = _cursor; + // 커서를 먼저 걸고 마지막에 개수를 자른다. 순서를 뒤집으면 앞 5개를 자른 + // 결과에 커서를 다시 대는 셈이 되어 다음 장이 비어 나온다. + if (cursor != null) query = query.startAfterDocument(cursor); + return query.limit(limit); + } + + static Map _map(Map data, String key) => + 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) + d.id, + }; + + Future _post(DocumentSnapshot> doc) async { + final data = doc.data() ?? const {}; + final uid = _backend.requireUid; + final liked = + (await doc.reference.collection('likes').doc(uid).get()).exists; + final author = data['authorUid']; + final images = [ + for (final src in [...?(data['imageUrls'] as List?)?.whereType()]) + ?await _imageUrl(src), + ]; + return LoungePost( + id: doc.id, + authorUid: author is String ? author : null, + isMine: author == uid, + liked: liked, + authorName: publicName(data['authorName']), + createdAt: data.dateOr('createdAt', DateTime.now()), + title: data.strOr('title'), + body: data.strOr('body'), + likes: data.intOr('likeCount'), + comments: data.intOr('commentCount'), + images: images, + ); + } + + /// 글 사진 칸의 값 → 화면이 그릴 URL. 새 글은 Storage **경로**를 담는다(규칙이 + /// 칸마다 정확한 경로만 받는다). 예전 글 · 시드의 `http` 주소는 그대로 쓴다. + /// 지워졌거나 읽지 못한 사진은 칸을 뺀다 — 깨진 칸을 그리지 않는다. + /// + /// ponytail: 사진마다 URL 조회가 한 번씩 붙는다. 목록이 무거워지면 URL 을 + /// 트리거가 글 문서에 비정규화해 적는다. + Future _imageUrl(String src) async { + if (src.startsWith('http')) return src; + try { + return await _backend.storage.ref(src).getDownloadURL(); + } catch (_) { + return null; + } + } + + /// 작성자 표시명 = 공개 닉네임(`publicName`, 없으면 「팬」). 공급자 실명 + /// (`displayName`)은 쓰지 않는다(M-7). 규칙이 같은 계산으로 대조한다. + Future _myName() async => storedPublicName( + (await _backend.db.doc('users/${_backend.requireUid}').get()) + .data()?['nickname'], + ); + + /// 글 쓰기 — 사진 업로드 → 글 문서 한 건. + /// + /// 사진은 `users/{uid}/lounge/` 아래에 올린다. 경로 첫 칸이 UID 여야 Storage + /// 규칙이 본인 쓰기로 받는다. 글 저장이 거부되면(다른 팬덤 · 사칭 등) 올린 + /// 사진을 지워 고아 파일을 남기지 않는다. + @override + Future createPost( + String body, { + List photos = const [], + }) async { + checkLoungePhotos(photos); + final uid = _backend.requireUid; + final ref = _backend.db.collection('posts').doc(); + final uploaded = []; + final paths = []; + try { + for (final (i, photo) in photos.indexed) { + // 파일 이름 = 글 ID-칸 번호. Storage · Firestore 규칙이 이 모양만 받는다. + final path = 'users/$uid/lounge/${ref.id}-$i'; + final file = _backend.storage.ref(path); + uploaded.add(file); + await file.putData( + photo.bytes, + SettableMetadata(contentType: photo.contentType), + ); + paths.add(path); + } + } catch (error) { + // 어떤 실패든 올린 것부터 치운다. 절반만 올라간 사진을 남기지 않는다. + await _discard(uploaded); + // Storage 규칙이 용량 · 형식을 서버에서 한 번 더 본다(B-44). + throw PhotoRejected( + error is FirebaseException && error.code == 'unauthorized' + ? appL10n.photoRejectedServer + : appL10n.photoUploadFailed, + ); + } + try { + await ref.set({ + 'fandomId': fandomId, + 'authorUid': uid, + 'authorName': await _myName(), + 'body': body, + 'imageUrls': paths, + 'likeCount': 0, + 'commentCount': 0, + 'visibility': 'visible', + 'createdAt': FieldValue.serverTimestamp(), + 'updatedAt': FieldValue.serverTimestamp(), + }); + } catch (_) { + await _discard(uploaded); + rethrow; + } + return _post(await ref.get()); + } + + static Future _discard(List files) async { + for (final f in files) { + try { + await f.delete(); + } on FirebaseException { + // 지우기 실패는 원래 오류를 가리지 않게 삼킨다 — 원인은 앞의 실패다. + } + } + } + + @override + Future updatePost(LoungePost post, String body) async { + await _backend.db.doc('posts/${post.id}').update({ + 'body': body, + 'updatedAt': FieldValue.serverTimestamp(), + }); + return post.copyWith(body: body); + } + + /// 글 삭제 — 규칙이 작성자 본인만 허용한다. 좋아요 · 댓글 · 사진은 + /// 서버 트리거(`fnPostCleanup`)가 지운다. + @override + Future deletePost(LoungePost post) => + _backend.db.doc('posts/${post.id}').delete(); + + /// 좋아요 — 문서 ID = uid 로 한 사람 한 번. `likeCount` 는 서버 트리거 + /// (`fnPostLikeCount`)가 하위 문서 수를 다시 세어 적는다 — 앱은 쓰지 못한다. + @override + Future toggleLike(LoungePost post) async { + final like = _backend.db.doc( + 'posts/${post.id}/likes/${_backend.requireUid}', + ); + if (post.liked) { + await like.delete(); + } else { + await like.set({'createdAt': FieldValue.serverTimestamp()}); + } + return post.copyWith( + liked: !post.liked, + likes: post.likes + (post.liked ? -1 : 1), + ); + } + + @override + Future> comments(String postId) async { + final uid = _backend.requireUid; + final blocked = await _blockedUids(); + final snap = await _backend.db + .collection('posts/$postId/comments') + .where('visibility', isEqualTo: 'visible') + .orderBy('createdAt') + .get(); + return [ + for (final d in snap.docs) + if (!blocked.contains(d.data()['authorUid'])) _comment(d, uid), + ]; + } + + static LoungeComment _comment( + DocumentSnapshot> d, + String uid, + ) { + final data = d.data() ?? const {}; + return LoungeComment( + id: d.id, + authorName: publicName(data['authorName']), + body: data.strOr('body'), + createdAt: data.dateOr('createdAt', DateTime.now()), + isMine: data['authorUid'] == uid, + ); + } + + @override + Future addComment(String postId, String body) async { + final uid = _backend.requireUid; + final ref = await _backend.db.collection('posts/$postId/comments').add({ + 'authorUid': uid, + 'authorName': await _myName(), + 'body': body, + 'visibility': 'visible', + 'createdAt': FieldValue.serverTimestamp(), + 'updatedAt': FieldValue.serverTimestamp(), + }); + return _comment(await ref.get(), uid); + } + + @override + Future deleteComment(String postId, String commentId) => + _backend.db.doc('posts/$postId/comments/$commentId').delete(); + + /// 신고 — 설계서 §2.6 `reports`. 문서 ID 를 「대상 + 신고자」로 고정해 같은 + /// 사람이 같은 글을 두 번 세지 못하게 한다(B-45 「신고자 수」 집계의 전제). + /// 두 번째 접수는 규칙이 덮어쓰기를 막아 거부된다. + @override + Future reportPost(LoungePost post, String reason) async { + final uid = _backend.requireUid; + final code = reportReasonCodes[reason]; + if (code == null) { + throw BackendException('E-PERM-01', appL10n.reportReasonUnknown); + } + try { + await _backend.db.doc('reports/post-${post.id}-$uid').set({ + 'reporterUid': uid, + 'targetType': 'post', + 'targetPath': 'posts/${post.id}', + 'reasonCode': code, + 'status': 'pending', + 'createdAt': FieldValue.serverTimestamp(), + }); + } on FirebaseException catch (error) { + if (error.code == 'permission-denied') { + throw BackendException('E-PERM-01', appL10n.reportAlready); + } + rethrow; + } + } + + /// 차단 — MY 의 차단 목록과 같은 경로 · 같은 메서드를 쓴다. + @override + Future blockAuthor(LoungePost post) async { + final target = post.authorUid; + if (target == null) { + throw BackendException('E-PERM-01', appL10n.blockUnavailable); + } + await FirestoreMyRepository( + _backend, + ).block(target, nickname: post.authorName); + (await _blockedUids()).add(target); + } +} diff --git a/app/lib/features/planet/widgets/action_sheet.dart b/app/lib/features/planet/widgets/action_sheet.dart new file mode 100644 index 0000000..118b9b2 --- /dev/null +++ b/app/lib/features/planet/widgets/action_sheet.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/i18n/app_locale.dart'; + +/// iOS 식 하단 시트 한 줄. +class ActionSheetItem { + const ActionSheetItem(this.label, this.onTap, {this.destructive = true}); + final String label; + final VoidCallback onTap; + + /// 시안의 신고 · 숨기기는 모두 #ff203b. + final bool destructive; +} + +/// 시안 `Frame 1984081055` — #2c2c2e r16 묶음(줄 58, 사이선 #6a6864) + 취소 줄 54, +/// 42dot Sans 20/500. 파괴 동작 #ff203b, 취소 #1895ff. +Future showActionSheet( + BuildContext context, + List items, +) { + TextStyle style(Color c) => TextStyle( + fontFamily: AppFonts.body, + fontSize: 20, + fontWeight: FontWeight.w500, + height: 1.3, + letterSpacing: -0.5, + color: c, + ); + Widget cell(String label, Color color, double height, VoidCallback onTap) => + InkWell( + onTap: onTap, + child: SizedBox( + height: height, + width: double.infinity, + child: Center(child: Text(label, style: style(color))), + ), + ); + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.6), + builder: (sheet) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: AppColors.cosmicSheet, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final (i, item) in items.indexed) ...[ + if (i > 0) + const Divider( + height: 1, + thickness: 1, + color: Color(0xFF6A6864), + ), + cell( + item.label, + item.destructive + ? const Color(0xFFFF203B) + : AppColors.onCosmic, + 58, + () { + Navigator.of(sheet).pop(); + item.onTap(); + }, + ), + ], + ], + ), + ), + const SizedBox(height: 8), + Material( + color: AppColors.cosmicSheet, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: cell( + context.l10n.commonCancel, + const Color(0xFF1895FF), + 54, + () => Navigator.of(sheet).pop(), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/app/lib/features/planet/widgets/fpi_hexagon.dart b/app/lib/features/planet/widgets/fpi_hexagon.dart new file mode 100644 index 0000000..d63822e --- /dev/null +++ b/app/lib/features/planet/widgets/fpi_hexagon.dart @@ -0,0 +1,143 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../planet_data.dart'; + +/// TOTAL FPI 오각 그래프 — Figma 명세 「TOTAL FPI 영역」 · 시안 `Frame 1984081147`(232×140). +/// +/// *데이터 : TOTAL FPI 값 · 육각형 그래프 · 규칙 : 실시간 데이터 기준 노출* +/// +/// 시안 — 다각형 114×108(외곽 #2c2c2e 2 + 안쪽 3단 1), 데이터 면 #5bd4de 20% · 선 1, +/// 꼭짓점 6 #64eff9. 축 이름 42dot Sans 10/500 #dde3e4 는 위 · 좌우 · 아래 두 칸에 놓는다. +/// 지표가 다섯이 아니면 축 이름을 꼭짓점 바깥에 방사형으로 놓는다. +class FpiHexagon extends StatelessWidget { + const FpiHexagon({super.key, required this.scores, required this.max}); + + final List scores; + final int max; + + static const size = Size(232, 140); + + @override + Widget build(BuildContext context) => SizedBox.fromSize( + size: size, + child: CustomPaint( + painter: _HexPainter( + scores: scores, + max: max, + labelStyle: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 10, + fontWeight: FontWeight.w500, + height: 1, + color: Color(0xFFDDE3E4), + ), + ), + ), + ); +} + +class _HexPainter extends CustomPainter { + _HexPainter({ + required this.scores, + required this.max, + required this.labelStyle, + }); + + final List scores; + final int max; + final TextStyle labelStyle; + + /// 격자 단 비율 — 시안 Polygon 3·4·5(93.9 · 63.9 · 32 / 114). + static const rings = [1.0, 0.82, 0.56, 0.28]; + + @override + void paint(Canvas canvas, Size size) { + if (scores.isEmpty) return; + final n = scores.length; + // 외접원 반지름 — 오각형 높이 108 = R(1 + cos 36°). + final radius = 108 / (1 + math.cos(math.pi / 5)) * size.height / 140; + final center = Offset(size.width / 2, size.height * 16 / 140 + radius); + + Offset at(int i, double r) { + // 12시 방향에서 시작해 시계 방향. + final a = -math.pi / 2 + (2 * math.pi * i) / n; + return center + Offset(math.cos(a) * r, math.sin(a) * r); + } + + Path poly(double r) { + final p = Path(); + for (var i = 0; i < n; i++) { + final o = at(i, r); + i == 0 ? p.moveTo(o.dx, o.dy) : p.lineTo(o.dx, o.dy); + } + return p..close(); + } + + for (final (i, k) in rings.indexed) { + canvas.drawPath( + poly(radius * k), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = i == 0 ? 2 : 1 + ..color = AppColors.cosmicOutline, + ); + } + + // 데이터 다각형. + final data = Path(); + for (var i = 0; i < n; i++) { + final o = at(i, radius * scores[i].normalized(max)); + i == 0 ? data.moveTo(o.dx, o.dy) : data.lineTo(o.dx, o.dy); + } + data.close(); + const line = Color(0xFF5BD4DE); + canvas + ..drawPath(data, Paint()..color = line.withValues(alpha: 0.2)) + ..drawPath( + data, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = line, + ); + + // 시안 좌표(232×140 기준) — 위 · 오른쪽 · 오른쪽 아래 · 왼쪽 아래 · 왼쪽. + const anchors = [ + Offset(116, 5), + Offset(201, 70), + Offset(179.5, 135), + Offset(59, 135), + Offset(25, 70), + ]; + final sx = size.width / 232, sy = size.height / 140; + for (var i = 0; i < n; i++) { + canvas.drawCircle( + at(i, radius * scores[i].normalized(max)), + 3, + Paint()..color = AppColors.neonCyan, + ); + final tp = TextPainter( + text: TextSpan(text: scores[i].metric.label, style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + final o = n == 5 + ? Offset(anchors[i].dx * sx, anchors[i].dy * sy) + : at(i, radius + 14); + tp.paint(canvas, o - Offset(tp.width / 2, tp.height / 2)); + } + } + + @override + bool shouldRepaint(_HexPainter old) => old.scores != scores || old.max != max; +} + +/// 그리기 비용 측정용 진입점. 사유는 [galaxyFieldPainterForTest] 와 같다. +@visibleForTesting +CustomPainter fpiHexagonPainterForTest({ + required List scores, + required int max, + TextStyle labelStyle = const TextStyle(fontSize: 10), +}) => _HexPainter(scores: scores, max: max, labelStyle: labelStyle); diff --git a/app/lib/features/planet/widgets/planet_detail_hero.dart b/app/lib/features/planet/widgets/planet_detail_hero.dart new file mode 100644 index 0000000..36d9356 --- /dev/null +++ b/app/lib/features/planet/widgets/planet_detail_hero.dart @@ -0,0 +1,258 @@ +import '../../../core/ui/hero_time_status.dart'; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../../../core/ui/live_planet.dart'; +import '../../../core/data/planet_visual_state.dart'; +import '../../home/home_screen.dart' show formatPop; +import '../../../core/i18n/app_locale.dart'; + +/// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543). +/// +/// 우주 배경 사진(`373:441126` IMAGE 채움) 위, 상단 104(상태 표시줄 48 + 내비 56) +/// 아래 영역에 개설자(아바타 26 · ABeeZee 16), 행성 일러스트 200, 규모 칩과 멤버 수, +/// 행성 이름 ABeeZee 46, 「FPI : n | 1인당 화력 : n」, 「가입중」 알약을 25 · 12 간격으로 +/// 쌓는다. 뒤로가기 · 더보기는 화면의 앱 바가 그린다(스크롤하면 앱 바만 남는다). +class PlanetDetailHero extends StatelessWidget { + const PlanetDetailHero({ + super.key, + required this.name, + required this.members, + required this.totalFpi, + required this.joined, + this.nebula = false, + this.onHeroParticipate, + this.onJoin, + this.onOpenHistory, + this.fandomId = '', + this.watchPlanet = noPlanetWatch, + this.stage, + this.ownerName, + this.ownerPhoto, + this.sizeLabel, + }); + + final VoidCallback? onOpenHistory; + final String fandomId; + final PlanetWatch watchPlanet; + final int? stage; + final String name; + final int members; + final int totalFpi; + final bool joined; + final VoidCallback? onHeroParticipate; + + /// 「가입하기」 알약을 눌렀을 때. 이미 가입했으면 null 이고 알약은 상태만 보인다. + final VoidCallback? onJoin; + + /// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다. + final bool nebula; + + /// 개설자 표시명. 서버에 읽을 자리가 없으면 null 이고 줄을 그리지 않는다 — + /// 예전에는 시안의 `BTS_Maker` 가 모든 행성에 박혀 있었다(인계 결함). + final String? ownerName; + + /// 개설자 사진(URL 또는 에셋). 없으면 회색 원. + final String? ownerPhoto; + + /// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다. + final String? sizeLabel; + + static const double designHeight = 543; + + /// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다. + static const double topBar = 104; + + static TextStyle _body(double size, Color color) => TextStyle( + fontFamily: AppFonts.body, + fontSize: size, + fontWeight: FontWeight.w500, + height: 1, + color: color, + ); + + @override + Widget build(BuildContext context) { + // 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다. + final perHead = members == 0 ? 0.0 : totalFpi / members; + const info = Color(0xFFDEDEDE); + + return Stack( + fit: StackFit.expand, + children: [ + LivePlanetLighting( + fandomId: fandomId, + watch: watchPlanet, + child: Image.asset( + 'assets/figma/bg-space-detail.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ), + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.7, 1], + colors: [ + Color(0x00141414), + Color(0x22141414), + AppColors.cosmicVoid, + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, topBar, 16, 0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (ownerName case final owner?) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 13, + backgroundColor: const Color(0xFFD9D9D9), + backgroundImage: switch (ownerPhoto) { + final p? when p.startsWith('http') => NetworkImage(p), + final p? => AssetImage(p), + null => null, + }, + ), + const SizedBox(width: 9), + Text( + owner, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 16, + height: 1.3, + letterSpacing: -0.4, + color: AppColors.onCosmic, + ), + ), + ], + ), + const SizedBox(height: 25), + ], + LivePlanet25D( + onOpenHistory: onOpenHistory, + fandomId: fandomId, + watch: watchPlanet, + image: 'assets/figma/planet-detail-hero.png', + size: 200, + glow: AppColors.neonViolet, + stage: stage, + ), + HeroTimeStatus( + fandomId: fandomId, + watch: watchPlanet, + onParticipate: onHeroParticipate, + ), + const SizedBox(height: 25), + // 규모 칩 + 멤버 수 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!nebula) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0x1A54C07D), + border: Border.all(color: const Color(0xFF54C07D)), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeLabel ?? context.l10n.planetSizeMedium, + style: _body(12, const Color(0xFF54C07D)), + ), + ), + const SizedBox(width: 4), + ], + Image.asset( + 'assets/figma/icon-person.png', + width: 20, + height: 20, + ), + const SizedBox(width: 3), + Text( + formatPop(members), + style: _body(14, AppColors.onCosmic), + ), + ], + ), + const SizedBox(height: 12), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 46, + height: 1, + color: AppColors.onCosmic, + ), + ), + const SizedBox(height: 12), + if (!nebula) ...[ + // 값이 커지면(백만 단위 FPI) 한 줄을 줄여 맞춘다. + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'FPI : ${formatPop(totalFpi)}', + style: _body(14, info), + ), + const SizedBox(width: 4), + Text('|', style: _body(14, info)), + const SizedBox(width: 4), + Text( + context.l10n.planetPerHead(perHead.toStringAsFixed(2)), + style: _body(14, info), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + Semantics( + button: onJoin != null, + child: GestureDetector( + onTap: onJoin, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + joined ? context.l10n.planetJoined : context.l10n.planetJoin, + style: _body( + 14, + const Color(0xFFD5D7D9), + ).copyWith(letterSpacing: -0.35), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +}