HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -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<EclipseState?> watch({String? fandomId, bool includeResults = false});
|
||||
|
||||
/// 최근 티커 이벤트. 최신순.
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20});
|
||||
|
||||
/// 공동 Shake 참여. [shakes] 는 단말이 감지한 흔들기 수이며
|
||||
/// **서버가 상한과 속도로 다시 판정한다**(별첨 1 ② 서버검증 원칙).
|
||||
Future<DefendResult> 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<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
||||
Stream.value(_snapshot(fandomId: fandomId));
|
||||
|
||||
@override
|
||||
Stream<List<LiveEvent>> 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<DefendResult> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<EclipseDefendScreen> createState() => _EclipseDefendScreenState();
|
||||
}
|
||||
|
||||
class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
with WidgetsBindingObserver {
|
||||
bool _foreground = true;
|
||||
String? _roundId;
|
||||
StreamSubscription<AccelerometerEvent>? _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<void> _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<EclipseState?>(
|
||||
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<Widget> 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;
|
||||
}
|
||||
@@ -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<String>? _myFandomId;
|
||||
|
||||
/// 팬덤별 공유 구독. 키가 빈 문자열이면 「내 팬덤」이다.
|
||||
final _watchers = <String, SharedStream<EclipseState?>>{};
|
||||
final _tickers = <String, SharedStream<List<LiveEvent>>>{};
|
||||
|
||||
Future<String> _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<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
||||
(_watchers['${fandomId ?? ''}#$includeResults'] ??= SharedStream<EclipseState?>(
|
||||
() => _openWatch(fandomId, includeResults),
|
||||
)).stream;
|
||||
|
||||
@override
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||
(_tickers['${fandomId ?? ''}#$limit'] ??= SharedStream<List<LiveEvent>>(
|
||||
() => _openTicker(fandomId, limit),
|
||||
)).stream;
|
||||
|
||||
Stream<EclipseState?> _openWatch(
|
||||
String? fandomId,
|
||||
bool includeResults,
|
||||
) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
||||
if (id.isEmpty) return Stream<EclipseState?>.value(null);
|
||||
return Stream<EclipseState?>.multi((out) {
|
||||
StreamSubscription<QuerySnapshot<Map<String, dynamic>>>? 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<List<LiveEvent>> _openTicker(String? fandomId, int limit) =>
|
||||
Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
||||
if (id.isEmpty) return Stream.value(const <LiveEvent>[]);
|
||||
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<DefendResult> 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<String, dynamic> data,
|
||||
required List<QueryDocumentSnapshot<Map<String, dynamic>>> 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<String, dynamic> 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'),
|
||||
);
|
||||
}
|
||||
@@ -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<EclipseState?>(
|
||||
// 대상 팬덤이 바뀌면 구독을 갈아탄다 — [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}');
|
||||
}
|
||||
@@ -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}',
|
||||
};
|
||||
@@ -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<PlanetNode> planets;
|
||||
|
||||
/// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」).
|
||||
/// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다.
|
||||
final List<PlanetNode> 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<GalaxySnapshot> 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<ZapResult> zap({
|
||||
required String fandomId,
|
||||
required int count,
|
||||
required String clientSeq,
|
||||
});
|
||||
|
||||
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
|
||||
/// E-FDM-04 로 거부된다.
|
||||
Future<void> setPrimary(String fandomId);
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
class SampleGalaxyRepository implements GalaxyRepository {
|
||||
const SampleGalaxyRepository({this.joined = true});
|
||||
|
||||
final bool joined;
|
||||
|
||||
@override
|
||||
Future<void> setPrimary(String fandomId) async {}
|
||||
|
||||
/// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다.
|
||||
@override
|
||||
Future<ZapResult> zap({
|
||||
required String fandomId,
|
||||
required int count,
|
||||
required String clientSeq,
|
||||
}) async => const ZapResult(txId: '', balanceAfter: -1);
|
||||
|
||||
@override
|
||||
Future<GalaxySnapshot> 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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<GalaxyHomeScreen> createState() => _GalaxyHomeScreenState();
|
||||
}
|
||||
|
||||
class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot = widget.repository.load();
|
||||
|
||||
/// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로.
|
||||
Future<void> _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<void>(
|
||||
builder: (_) => GalaxyScreen(
|
||||
repository: widget.repository,
|
||||
watchPlanet: widget.watchPlanet,
|
||||
planetRepository: widget.planetRepository,
|
||||
eclipse: widget.eclipse,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다.
|
||||
Future<void> _createNebula() async {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final myPop = (await _snapshot).myPop;
|
||||
final fandomId = await navigator.push(
|
||||
MaterialPageRoute<String>(
|
||||
builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop),
|
||||
),
|
||||
);
|
||||
if (fandomId == null || !mounted) return;
|
||||
setState(() { _snapshot = widget.repository.load(); });
|
||||
await navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
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<GalaxySnapshot>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<GalaxySnapshot> load() async {
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
|
||||
final userSnap = await db.doc('users/$uid').get();
|
||||
final user = userSnap.data() ?? const <String, dynamic>{};
|
||||
// 대표 팬덤(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 <String, dynamic>{};
|
||||
final events = (await eventF).docs;
|
||||
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
||||
|
||||
// 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는
|
||||
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
|
||||
// Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의
|
||||
// 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다.
|
||||
bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) => d.data()['stage'] == 0;
|
||||
final formal = fandoms.where((d) => !isNebula(d)).toList();
|
||||
final planets = <PlanetNode>[
|
||||
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<String, dynamic> 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<void> setPrimary(String fandomId) =>
|
||||
_backend.call('fnPrimaryFandomSet', {'fandomId': fandomId});
|
||||
|
||||
/// ZAP — 행성 성장 기여(B-23). 차감은 `fn-pop-spend` 가 한다.
|
||||
///
|
||||
/// `count` 를 함께 보내 **원장 한 행에 묶음 횟수를 남긴다**. 연타를 한 호출로
|
||||
/// 묶으면 `amount` 만으로는 횟수를 복원할 수 없어, MY 의 「Zap N 회」가 행 수로
|
||||
/// 계산되어 실제보다 작게 나온다.
|
||||
@override
|
||||
Future<ZapResult> 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 <String, dynamic>{}).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<int>(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<String, dynamic> data) => GalaxyEvent(
|
||||
kind: data.strOr('kind') == 'eclipse'
|
||||
? GalaxyEventKind.eclipse
|
||||
: GalaxyEventKind.itemUsed,
|
||||
message: data.strOr('message'),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<GalaxySearchScreen> createState() => _GalaxySearchScreenState();
|
||||
}
|
||||
|
||||
class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot;
|
||||
final _controller = TextEditingController();
|
||||
|
||||
/// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다.
|
||||
final List<String> _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<GalaxySnapshot>(
|
||||
future: _snapshot,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final planets = snap.data?.planets ?? const <PlanetNode>[];
|
||||
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<String> terms;
|
||||
final VoidCallback onClear;
|
||||
final ValueChanged<String> 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlanetNode>? onOpenHistory;
|
||||
final List<PlanetNode> planets;
|
||||
final PlanetNode? selected;
|
||||
final ValueChanged<PlanetNode> 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<PlanetNode> 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<PlanetNode> 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<PlanetNode> a, List<PlanetNode> 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<PlanetNode> planets,
|
||||
}) => _BackdropPainter(
|
||||
planets: planets,
|
||||
outerRank: GalaxyField.outerRankOf(planets),
|
||||
);
|
||||
|
||||
/// 별밭 층의 그리기 비용.
|
||||
@visibleForTesting
|
||||
CustomPainter galaxyStarfieldPainterForTest() => const _StarfieldPainter();
|
||||
@@ -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<GalaxyMiniMap> createState() => _GalaxyMiniMapState();
|
||||
}
|
||||
|
||||
class _GalaxyMiniMapState extends State<GalaxyMiniMap>
|
||||
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<int>(0));
|
||||
GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame);
|
||||
|
||||
final ValueNotifier<int> _frame;
|
||||
|
||||
/// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구.
|
||||
static List<String> hudLines(PlanetNode p) => [
|
||||
p.name.toUpperCase(),
|
||||
'RANK #${p.rank}',
|
||||
'FPI: ${p.fpi}',
|
||||
'LIVE: ${formatPop(p.online)}',
|
||||
];
|
||||
|
||||
final _bodies = <String, _Body>{};
|
||||
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<PlanetNode> 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<int>(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 = <double>[];
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<ZapButton> createState() => _ZapButtonState();
|
||||
}
|
||||
|
||||
class _ZapButtonState extends State<ZapButton> {
|
||||
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>(
|
||||
() => 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>(
|
||||
() => 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<ZapFeedback> createState() => _ZapFeedbackState();
|
||||
}
|
||||
|
||||
class _ZapFeedbackState extends State<ZapFeedback>
|
||||
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 = <String>{};
|
||||
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;
|
||||
}
|
||||
@@ -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<String> 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<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<void>(
|
||||
builder: (_) => PlanetCreateScreen(nebulaName: nebulaName),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: HomeHeader(pop: pop),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<QuestSummary> quests;
|
||||
final List<BriefingItem> briefings;
|
||||
final CampaignHighlight? campaign;
|
||||
final List<InsideDigest> insides;
|
||||
|
||||
/// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85)
|
||||
/// `heroGiftProducts` 에서 온다.
|
||||
final List<ItemProduct> 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<HomeSnapshot> load();
|
||||
Stream<PlanetGrowth> 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<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
});
|
||||
|
||||
/// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy`
|
||||
/// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets].
|
||||
Future<NebulaTargets> 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<String, dynamic>? 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<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
|
||||
|
||||
@override
|
||||
Future<({String fandomId, bool primaryChanged})> createNebula(
|
||||
String name,
|
||||
) async => (fandomId: 'bts', primaryChanged: true);
|
||||
|
||||
@override
|
||||
Future<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
// 브리핑 표본이 `sourceAt` 에 DateTime 을 담아 const 로 둘 수 없다.
|
||||
Future<HomeSnapshot> 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<BriefingItem> _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: '팬미팅 비하인드 영상 공개 일정이 예고되었습니다.',
|
||||
),
|
||||
];
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<PlanetGrowth> 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<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
}) => _backend.call('fnPlanetCreate', {
|
||||
'fandomId': fandomId,
|
||||
'planetType': planetType,
|
||||
'colorKey': colorKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<NebulaTargets> nebulaTargets() async {
|
||||
try {
|
||||
return nebulaTargetsFrom(
|
||||
(await _backend.db.doc('config/policy').get()).data(),
|
||||
);
|
||||
} catch (_) {
|
||||
return defaultNebulaTargets;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeSnapshot> 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 <String, dynamic>{};
|
||||
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 <String, dynamic>{};
|
||||
final fandom = fandomF == null
|
||||
? const <String, dynamic>{}
|
||||
: ((await fandomF).data() ?? const <String, dynamic>{});
|
||||
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 <String, dynamic>{};
|
||||
|
||||
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<String, dynamic> 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<String, dynamic> 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<PlanetGrowth> _growth(
|
||||
Map<String, dynamic> 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<String, dynamic> quest,
|
||||
Map<String, dynamic>? progress,
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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);
|
||||
@@ -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<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
late Future<HomeSnapshot> _snapshot;
|
||||
Future<GalaxySnapshot>? _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<void> _createThenReload(Widget screen) async {
|
||||
final created = await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<String>(builder: (_) => screen),
|
||||
);
|
||||
if (created == null || !mounted) return;
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
setState(() { _galaxy = repository.load(); });
|
||||
}
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final next = widget.repository.load();
|
||||
setState(() => _snapshot = next);
|
||||
await next;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
// 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서
|
||||
// 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다.
|
||||
body: FutureBuilder<HomeSnapshot>(
|
||||
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<List<LiveEvent>>(
|
||||
key: ValueKey('live-$fandomId'),
|
||||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream.value(const <LiveEvent>[])
|
||||
: 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<ItemProduct> 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<void>(
|
||||
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<void> Function(Widget screen) onCreate;
|
||||
|
||||
final NotificationRepository notifications;
|
||||
|
||||
final HomeSnapshot data;
|
||||
final Future<GalaxySnapshot>? galaxy;
|
||||
final ZapController? zap;
|
||||
final HomeRepository repository;
|
||||
final PlanetWatch watchPlanet;
|
||||
final PlanetRepositoryFactory? planetRepository;
|
||||
final EclipseRepository eclipse;
|
||||
final Future<void> 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<PlanetGrowth>(
|
||||
key: ValueKey((repository, data.fandomId)),
|
||||
create: () => repository.watchGrowth(data.fandomId),
|
||||
builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
builder: (_) => PlanetDetailScreen(
|
||||
repository: planetRepository!(data.fandomId),
|
||||
watchPlanet: watchPlanet,
|
||||
eclipse: eclipse,
|
||||
),
|
||||
),
|
||||
),
|
||||
onOpenHistory: planetRepository == null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<void> 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<void>(
|
||||
builder: (_) => CampaignListScreen(repository: repository),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 홈의 오늘의 퀘스트에서 퀘스트 목록으로 — 화면 대조 원장 F1 → F4.
|
||||
///
|
||||
/// 보유 POP 을 함께 넘긴다. 목록 헤더가 같은 값을 다시 읽으면 왕복이 하나 늘고,
|
||||
/// 두 화면의 숫자가 한순간 어긋난다.
|
||||
void _openQuests(
|
||||
BuildContext context,
|
||||
QuestRepository repository,
|
||||
int popBalance,
|
||||
) {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
QuestListScreen(repository: repository, myPop: popBalance),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NebulaCreateScreen> createState() => _NebulaCreateScreenState();
|
||||
}
|
||||
|
||||
class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
|
||||
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<void> _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<bool> showConfirmDialog(BuildContext context, String message) async {
|
||||
final r = await showDialog<bool>(
|
||||
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<void> showAlertDialog(BuildContext context, String message) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -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<PlanetCreateScreen> createState() => _PlanetCreateScreenState();
|
||||
}
|
||||
|
||||
class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
||||
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<void> _confirm() async {
|
||||
final ok = await showDialog<bool>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<BriefingItem> items;
|
||||
|
||||
/// 원문보기 동작. 기본값은 기본 브라우저로 넘기는 것이고, 테스트가 대신 받는다 —
|
||||
/// 위젯 테스트에서 실제 브라우저를 띄울 수는 없다.
|
||||
final Future<void> 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<void> _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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<BriefingItem> 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<BriefingItem> items;
|
||||
final String fandomName;
|
||||
final String? link;
|
||||
|
||||
/// 공유 시트 호출. 테스트가 대신 받는다 — 위젯 테스트에서 OS 공유 시트를 띄울 수 없다.
|
||||
final Future<void> Function(ShareParams params)? share;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required List<BriefingItem> items,
|
||||
required String fandomName,
|
||||
String? link,
|
||||
Future<void> Function(ShareParams params)? share,
|
||||
}) => showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: context.l10n.commonClose,
|
||||
barrierColor: AppColors.cosmicVoid,
|
||||
pageBuilder: (_, _, _) => BriefingShareDialog(
|
||||
items: items,
|
||||
fandomName: fandomName,
|
||||
link: link,
|
||||
share: share,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
State<BriefingShareDialog> createState() => _BriefingShareDialogState();
|
||||
}
|
||||
|
||||
class _BriefingShareDialogState extends State<BriefingShareDialog> {
|
||||
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<XFile?> _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<void> _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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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<HomeHero> createState() => _HomeHeroState();
|
||||
}
|
||||
|
||||
class _HomeHeroState extends State<HomeHero> {
|
||||
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<PlanetVisualState>(
|
||||
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<void> _open(BuildContext context) async {
|
||||
final pick = await showModalBottomSheet<VoidCallback>(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> showItemSheet(
|
||||
BuildContext context, {
|
||||
required List<ItemProduct> items,
|
||||
required bool purchase,
|
||||
String fandomId = '',
|
||||
}) => showModalBottomSheet<void>(
|
||||
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<ItemProduct> 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<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!)}';
|
||||
@@ -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<FpiRankingScreen> createState() => _FpiRankingScreenState();
|
||||
}
|
||||
|
||||
class _FpiRankingScreenState extends State<FpiRankingScreen> {
|
||||
late final Future<FpiRanking> _ranking = widget.repository.planetRanking(
|
||||
metric: widget.metric,
|
||||
);
|
||||
|
||||
void _openUsers(FpiRankRow planet) =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<FpiUserRankingScreen> createState() => _FpiUserRankingScreenState();
|
||||
}
|
||||
|
||||
class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
|
||||
FpiRankScope _scope = FpiRankScope.live;
|
||||
late Future<FpiRanking> _ranking = _load();
|
||||
|
||||
Future<FpiRanking> _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<void> _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<FpiRanking> future;
|
||||
final String myLabel;
|
||||
final String emptyLabel;
|
||||
final ValueChanged<FpiRankRow>? onTapRow;
|
||||
|
||||
/// 행성 랭킹이면 내 줄 아바타가 행성 그림이다.
|
||||
final bool planets;
|
||||
|
||||
/// 사용자 랭킹의 범위 제목과 그 선택.
|
||||
final String? scopeLabel;
|
||||
final VoidCallback? onPickScope;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<FpiRanking>(
|
||||
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<FpiRankRow> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<AccelerometerEvent>? accelerometer;
|
||||
@override
|
||||
State<HeroParticipationSheet> createState() => _HeroParticipationSheetState();
|
||||
}
|
||||
|
||||
class _HeroParticipationSheetState extends State<HeroParticipationSheet>
|
||||
with WidgetsBindingObserver {
|
||||
StreamSubscription<AccelerometerEvent>? _sensor;
|
||||
PlanetVisualState? _state;
|
||||
final _received = <String, HeroDropReceipt>{};
|
||||
final _unavailable = <String>{};
|
||||
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<void> _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<PlanetVisualState>(
|
||||
key: ValueKey(widget.fandomId),
|
||||
create: () => widget.watch(widget.fandomId),
|
||||
builder: (context, state) {
|
||||
_state = state;
|
||||
final available = _next != null;
|
||||
final total = _received.values.fold<int>(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),
|
||||
),
|
||||
]),
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<PickedPhoto> photos;
|
||||
}
|
||||
|
||||
/// 라운지 쓰기 실패를 사용자 문구로. 사유가 있는 실패는 그 문구를, 규칙 거부는
|
||||
/// 권한 안내를, 나머지는 재시도 안내를 띄운다.
|
||||
String loungeError(Object error) => switch (error) {
|
||||
PhotoRejected(:final message) => message,
|
||||
BackendException(:final message) => message,
|
||||
FirebaseException(code: 'permission-denied') => appL10n.loungeMembersOnly,
|
||||
_ => appL10n.loungeFailed,
|
||||
};
|
||||
|
||||
/// 라운지 글 공유 — 시스템 공유 시트로 본문 문구를 보낸다.
|
||||
Future<void> 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<LoungePost?>? onChanged;
|
||||
|
||||
/// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다.
|
||||
final TranslationRepository? translation;
|
||||
|
||||
@override
|
||||
State<LoungePostScreen> createState() => _LoungePostScreenState();
|
||||
}
|
||||
|
||||
class _LoungePostScreenState extends State<LoungePostScreen> {
|
||||
late LoungePost _post = widget.post;
|
||||
List<LoungeComment>? _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<void> _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<void> _run(Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (mounted) _toast(context, loungeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _like() =>
|
||||
_run(() async => _update(await widget.repository.toggleLike(_post)));
|
||||
|
||||
Future<bool> _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<void> _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<String>(
|
||||
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<LoungeComment> comments,
|
||||
TranslationRepository? t,
|
||||
Map<String, List<TranslationEntry>> 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<void> _moderate(BuildContext context, String action) async {
|
||||
final post = _post;
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
final updated = await Navigator.of(context, rootNavigator: true)
|
||||
.push<LoungePost>(
|
||||
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<String>(
|
||||
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<LoungePost> Function(PostDraft draft)? onSubmit;
|
||||
|
||||
@override
|
||||
State<LoungeComposeScreen> createState() => _LoungeComposeScreenState();
|
||||
}
|
||||
|
||||
class _LoungeComposeScreenState extends State<LoungeComposeScreen> {
|
||||
late final _body = TextEditingController(text: widget.initialBody);
|
||||
final _photos = <PickedPhoto>[];
|
||||
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<void> _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<void> _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')}';
|
||||
@@ -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<String> get defaultReportReasons => [
|
||||
appL10n.reportDefamation,
|
||||
appL10n.reportInappropriate,
|
||||
appL10n.reportSpam,
|
||||
appL10n.reportPrivacy,
|
||||
];
|
||||
|
||||
/// 신고 사유 → `reports.reasonCode`. 규칙(`firestore.rules` reports)이 이 네 코드만
|
||||
/// 받는다. 화면 문구가 바뀌어도 Admin 이 집계하는 코드는 그대로다.
|
||||
Map<String, String> 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<String>? reasons;
|
||||
|
||||
@override
|
||||
State<ReportScreen> createState() => _ReportScreenState();
|
||||
}
|
||||
|
||||
class _ReportScreenState extends State<ReportScreen> {
|
||||
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<void> _submit() async {
|
||||
final ok = await confirmOverlay(
|
||||
context,
|
||||
context.l10n.reportConfirm,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
Navigator.pop(context, _reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// 차단 안내 모달. 확인하면 차단 처리 후 목록으로 돌아간다.
|
||||
///
|
||||
/// 문구는 시안 그대로 — 글·회원은 「이 회원의 모든 게시물을 차단할까요?」(`252:163306`),
|
||||
/// 성운은 「이 성운을 차단할까요?」(`373:442417`). [nickname] 은 부르는 쪽 호환용이다.
|
||||
Future<bool> 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<bool> 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<bool>(
|
||||
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;
|
||||
}
|
||||
@@ -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<T>(
|
||||
Iterable<T> 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<FpiRankRow> 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<String> 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<PickedPhoto> 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<FpiScore> scores;
|
||||
final int members;
|
||||
final List<LoungePost> 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<PlanetHistoryEntry> items;
|
||||
final String? nextCursor;
|
||||
}
|
||||
|
||||
abstract interface class PlanetRepository {
|
||||
Future<PlanetHistoryPage> history({String? cursor, bool personal = false});
|
||||
Future<HeroDropReceipt> claimHeroDrop(String dropId);
|
||||
Future<PlanetDetail> load({required bool nebula});
|
||||
|
||||
/// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다.
|
||||
Future<void> join();
|
||||
|
||||
/// 명세 — *최초 5개, 스크롤 시 5개씩 호출*.
|
||||
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
|
||||
|
||||
/// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44).
|
||||
Future<LoungePost> createPost(String body, {List<PickedPhoto> photos = const []});
|
||||
|
||||
/// 내 글 본문 수정. 사진은 바꾸지 않는다.
|
||||
Future<LoungePost> updatePost(LoungePost post, String body);
|
||||
|
||||
Future<void> deletePost(LoungePost post);
|
||||
|
||||
/// 좋아요 켜고 끄기. 수는 서버 트리거가 다시 센다 — 돌려주는 값은 화면용 예측이다.
|
||||
Future<LoungePost> toggleLike(LoungePost post);
|
||||
|
||||
Future<List<LoungeComment>> comments(String postId);
|
||||
|
||||
Future<LoungeComment> addComment(String postId, String body);
|
||||
|
||||
Future<void> deleteComment(String postId, String commentId);
|
||||
|
||||
/// 신고 접수 — `reports` 에 쌓고 Admin 이 처리한다(B-45).
|
||||
Future<void> reportPost(LoungePost post, String reason);
|
||||
|
||||
/// 작성자 차단 — `users/{uid}/blocks/{authorUid}`. 이후 목록에서 빠진다.
|
||||
Future<void> blockAuthor(LoungePost post);
|
||||
|
||||
/// 지표별 **행성** 랭킹 — Figma `546:14628`·`546:14639`.
|
||||
///
|
||||
/// 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성 중 최근 7 일 유효 활동
|
||||
/// 존재 행성*. 서버가 `fandoms.rankingEligible` 로 표시한다.
|
||||
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30});
|
||||
|
||||
/// 지표별 **사용자** 랭킹 — Figma `546:14668`·`546:14674`.
|
||||
/// 행성 랭킹의 한 행을 눌러 들어오므로 대상 행성을 받는다.
|
||||
Future<FpiRanking> userRanking({
|
||||
required FpiMetric metric,
|
||||
required String fandomId,
|
||||
int limit = 30,
|
||||
});
|
||||
|
||||
/// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」).
|
||||
Future<FpiRanking> 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<void> join() async {}
|
||||
|
||||
@override
|
||||
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []);
|
||||
@override
|
||||
Future<HeroDropReceipt> 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<int>) 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<int>)>[
|
||||
('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 = <String, int>{
|
||||
'bts': 12840,
|
||||
'newjeans': 11230,
|
||||
'seventeen': 10450,
|
||||
'ive': 8320,
|
||||
'aespa': 7640,
|
||||
'lesserafim': 6910,
|
||||
};
|
||||
|
||||
/// 내 행성 안의 사용자 표본.
|
||||
static const _users = <(String, String, List<int>)>[
|
||||
('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<int>)>[
|
||||
('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<int>)> 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 = <FpiRankRow>[
|
||||
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<PlanetDetail> 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<List<LoungePost>> morePosts({required int offset, int limit = 5}) async =>
|
||||
_posts(offset, limit);
|
||||
|
||||
/// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다.
|
||||
static final _comments = <String, List<LoungeComment>>{};
|
||||
|
||||
@override
|
||||
Future<LoungePost> createPost(
|
||||
String body, {
|
||||
List<PickedPhoto> 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<LoungePost> updatePost(LoungePost post, String body) async =>
|
||||
post.copyWith(body: body);
|
||||
|
||||
@override
|
||||
Future<void> deletePost(LoungePost post) async {}
|
||||
|
||||
@override
|
||||
Future<LoungePost> toggleLike(LoungePost post) async => post.copyWith(
|
||||
liked: !post.liked,
|
||||
likes: post.likes + (post.liked ? -1 : 1),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<LoungeComment>> comments(String postId) async => [
|
||||
...?_comments[postId],
|
||||
];
|
||||
|
||||
@override
|
||||
Future<LoungeComment> 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<void> deleteComment(String postId, String commentId) async =>
|
||||
_comments[postId]?.removeWhere((c) => c.id == commentId);
|
||||
|
||||
@override
|
||||
Future<void> reportPost(LoungePost post, String reason) async {}
|
||||
|
||||
@override
|
||||
Future<void> blockAuthor(LoungePost post) async {}
|
||||
|
||||
@override
|
||||
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) async =>
|
||||
_rank(_planets, metric, myId: _myFandomId, limit: limit);
|
||||
|
||||
/// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면
|
||||
/// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가
|
||||
/// 인자로 받으므로(행성 랭킹에서 누른 행) 필드가 아니라 인자로 판정한다.
|
||||
///
|
||||
/// ponytail: 행의 이름·수치는 행성이 달라도 같다. 표본을 여섯 벌 더 지어내는
|
||||
/// 것이라 서버가 붙으면 버릴 값이다. 발주사가 지적한 「같은 Ranking 반복」은
|
||||
/// 지표(metric)축에서 갈리므로 그쪽 가드(f3 회귀 테스트)로 지킨다.
|
||||
@override
|
||||
Future<FpiRanking> userRanking({
|
||||
required FpiMetric metric,
|
||||
required String fandomId,
|
||||
int limit = 30,
|
||||
}) async => _rank(
|
||||
_users,
|
||||
metric,
|
||||
myId: fandomId == _myFandomId ? 'u-me' : null,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) async =>
|
||||
// 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713).
|
||||
_rank(_countries, metric, myId: 'KR', limit: limit);
|
||||
|
||||
static List<LoungePost> _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),
|
||||
),
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PlanetHistoryScreen> createState() => _PlanetHistoryScreenState();
|
||||
}
|
||||
|
||||
class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
||||
final _items = <PlanetHistoryEntry>[];
|
||||
String? _cursor;
|
||||
bool _loading = false;
|
||||
bool _loaded = false;
|
||||
bool _failed = false;
|
||||
bool _personal = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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<bool>(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<PlanetHistoryPage> 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 = <PlanetHistoryEntry>[];
|
||||
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<HeroDropReceipt> 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<Map<String, dynamic>>? _cursor;
|
||||
|
||||
/// 집계 축 키. 지표 이름을 그대로 쓴다 — 설계서 §4.1 의 `fpiAxes` 키와 같다.
|
||||
/// 두 벌을 두면 집계 축과 화면 표기가 갈린다.
|
||||
static String _axisKey(FpiMetric metric) => metric.name;
|
||||
|
||||
@override
|
||||
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
|
||||
|
||||
@override
|
||||
Future<PlanetDetail> 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 <String, dynamic>{};
|
||||
final snapshots = (await snapshotF).docs;
|
||||
final memberSnap = await memberF;
|
||||
final member = memberSnap.data() ?? const <String, dynamic>{};
|
||||
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 <String, dynamic>{}
|
||||
: 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<FpiRanking> 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 = <FpiRankRow>[
|
||||
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<String, dynamic> data, String axis) {
|
||||
final v = _map(data, 'fpiAxes')[axis];
|
||||
return v is num ? v : 0;
|
||||
}
|
||||
|
||||
/// 행성 랭킹 한 줄. 목록 안이든 밖이든 **같은 필드에서** 만든다.
|
||||
FpiRankRow _planetRow(
|
||||
Map<String, dynamic> 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<FpiRanking> 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 = <FpiRankRow>[
|
||||
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<FpiRanking> 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 <String, dynamic>{}).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 <String, dynamic>{}).strOr('countryCode', 'ZZ');
|
||||
|
||||
final list = <FpiRankRow>[];
|
||||
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<List<LoungePost>> morePosts({
|
||||
required int offset,
|
||||
int limit = pageSize,
|
||||
}) {
|
||||
// offset 0 은 「처음부터 다시」라는 뜻이다. 커서를 버리고 첫 장을 읽는다.
|
||||
if (offset == 0) _cursor = null;
|
||||
return _page(limit);
|
||||
}
|
||||
|
||||
/// 한 장을 읽되 **차단한 작성자의 글은 뺀다.** 한 장이 통째로 빠지면 화면이
|
||||
/// 「끝」으로 오인하므로 서버에 글이 남아 있는 동안 다음 장을 더 읽는다.
|
||||
Future<List<LoungePost>> _page(int limit) async {
|
||||
final blocked = await _blockedUids();
|
||||
final out = <QueryDocumentSnapshot<Map<String, dynamic>>>[];
|
||||
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<Map<String, dynamic>> _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<String, dynamic> _map(Map<String, dynamic> data, String key) =>
|
||||
Map<String, dynamic>.from((data[key] as Map?) ?? const <String, dynamic>{});
|
||||
|
||||
/// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다.
|
||||
Set<String>? _blocked;
|
||||
|
||||
Future<Set<String>> _blockedUids() async => _blocked ??= {
|
||||
for (final d in (await _backend.db
|
||||
.collection('users/${_backend.requireUid}/blocks')
|
||||
.get())
|
||||
.docs)
|
||||
d.id,
|
||||
};
|
||||
|
||||
Future<LoungePost> _post(DocumentSnapshot<Map<String, dynamic>> doc) async {
|
||||
final data = doc.data() ?? const <String, dynamic>{};
|
||||
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<String>()])
|
||||
?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<String?> _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<String> _myName() async => storedPublicName(
|
||||
(await _backend.db.doc('users/${_backend.requireUid}').get())
|
||||
.data()?['nickname'],
|
||||
);
|
||||
|
||||
/// 글 쓰기 — 사진 업로드 → 글 문서 한 건.
|
||||
///
|
||||
/// 사진은 `users/{uid}/lounge/` 아래에 올린다. 경로 첫 칸이 UID 여야 Storage
|
||||
/// 규칙이 본인 쓰기로 받는다. 글 저장이 거부되면(다른 팬덤 · 사칭 등) 올린
|
||||
/// 사진을 지워 고아 파일을 남기지 않는다.
|
||||
@override
|
||||
Future<LoungePost> createPost(
|
||||
String body, {
|
||||
List<PickedPhoto> photos = const [],
|
||||
}) async {
|
||||
checkLoungePhotos(photos);
|
||||
final uid = _backend.requireUid;
|
||||
final ref = _backend.db.collection('posts').doc();
|
||||
final uploaded = <Reference>[];
|
||||
final paths = <String>[];
|
||||
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<void> _discard(List<Reference> files) async {
|
||||
for (final f in files) {
|
||||
try {
|
||||
await f.delete();
|
||||
} on FirebaseException {
|
||||
// 지우기 실패는 원래 오류를 가리지 않게 삼킨다 — 원인은 앞의 실패다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LoungePost> 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<void> deletePost(LoungePost post) =>
|
||||
_backend.db.doc('posts/${post.id}').delete();
|
||||
|
||||
/// 좋아요 — 문서 ID = uid 로 한 사람 한 번. `likeCount` 는 서버 트리거
|
||||
/// (`fnPostLikeCount`)가 하위 문서 수를 다시 세어 적는다 — 앱은 쓰지 못한다.
|
||||
@override
|
||||
Future<LoungePost> 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<List<LoungeComment>> 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<Map<String, dynamic>> d,
|
||||
String uid,
|
||||
) {
|
||||
final data = d.data() ?? const <String, dynamic>{};
|
||||
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<LoungeComment> 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<void> deleteComment(String postId, String commentId) =>
|
||||
_backend.db.doc('posts/$postId/comments/$commentId').delete();
|
||||
|
||||
/// 신고 — 설계서 §2.6 `reports`. 문서 ID 를 「대상 + 신고자」로 고정해 같은
|
||||
/// 사람이 같은 글을 두 번 세지 못하게 한다(B-45 「신고자 수」 집계의 전제).
|
||||
/// 두 번째 접수는 규칙이 덮어쓰기를 막아 거부된다.
|
||||
@override
|
||||
Future<void> 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<void> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> showActionSheet(
|
||||
BuildContext context,
|
||||
List<ActionSheetItem> 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<void>(
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<FpiScore> 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<FpiScore> 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<FpiScore> scores,
|
||||
required int max,
|
||||
TextStyle labelStyle = const TextStyle(fontSize: 10),
|
||||
}) => _HexPainter(scores: scores, max: max, labelStyle: labelStyle);
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user