1092 lines
41 KiB
Dart
1092 lines
41 KiB
Dart
import '../planet/planet_history_screen.dart';
|
||
import '../planet/hero_participation_sheet.dart';
|
||
import '../../core/ui/hero_time_status.dart';
|
||
import '../../core/ui/planet_fx_scope.dart';
|
||
import '../../core/ui/live_planet.dart';
|
||
import 'zap_controller.dart';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import '../../core/data/planet_visual_state.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
|
||
import '../../core/theme/tokens.dart';
|
||
import '../../core/ui/live_stream.dart';
|
||
import '../eclipse/eclipse_data.dart';
|
||
import '../eclipse/eclipse_defend_screen.dart';
|
||
import '../eclipse/widgets/eclipse_banner.dart';
|
||
import '../eclipse/widgets/live_proof.dart';
|
||
import '../../core/format.dart';
|
||
import 'galaxy_data.dart';
|
||
import '../planet/planet_data.dart';
|
||
import 'galaxy_motion.dart';
|
||
import '../planet/planet_detail_screen.dart';
|
||
import 'galaxy_search_screen.dart';
|
||
import 'widgets/galaxy_field.dart';
|
||
import 'widgets/zap_button.dart';
|
||
import 'widgets/zap_feedback.dart';
|
||
import '../../core/i18n/app_locale.dart';
|
||
|
||
/// GALAXY 탭 루트 — Figma `03_Wireframe` 「갤럭시맵 화면」(`354:220228`).
|
||
///
|
||
/// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다.
|
||
///
|
||
/// 명세대로 갖춘 것
|
||
/// - 3d갤럭시 브라우징 줌인·줌아웃, 터치 이동 → [InteractiveViewer]
|
||
/// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동
|
||
/// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로
|
||
/// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면
|
||
/// - Zap 하단 고정, 가입·생성한 사용자에게만 노출
|
||
/// - 이벤트 메시지 우→좌 1회 슬라이드
|
||
///
|
||
/// 2026-09-09 검토회신서 3절이 지적한 것과 그 답
|
||
/// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로
|
||
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다. 이동(팬)의
|
||
/// 관성은 [InteractiveViewer] 가 이미 가진 플링을 그대로 쓴다.
|
||
/// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라
|
||
/// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시
|
||
/// 잡으므로 도는 행성도 정확히 잡는다.
|
||
/// - *선택 행성 정보가 화면 최상단 고정 패널이다* → HUD 를 행성에 붙였다
|
||
/// ([_PlanetHud]). Figma 명세도 *「HUD - 행성 상단/하단」*이다.
|
||
/// - *깊이감이 부족하다* → 별밭 시차·깊이 축척·대기 원근·깊이 순 겹침
|
||
/// ([GalaxyField] · [GalaxyStarfield]).
|
||
///
|
||
/// 별첨B 「GALAXY 중심」 확정값은 *기본은 내 Primary 행성/성운 중심, Ranking View 는
|
||
/// FPI 상위 중심*이다. 진입은 내 행성 중심이고, 우측 하단 Ranking View 스위치를 켜면
|
||
/// FPI 1위 행성으로 중심이 옮겨 간다([GalaxySnapshot.rankingView]).
|
||
class GalaxyScreen extends StatefulWidget {
|
||
const GalaxyScreen({
|
||
super.key,
|
||
this.watchPlanet = noPlanetWatch,
|
||
required this.repository,
|
||
required this.planetRepository,
|
||
required this.eclipse,
|
||
});
|
||
|
||
final GalaxyRepository repository;
|
||
final PlanetWatch watchPlanet;
|
||
|
||
/// HUD 를 눌러 들어가는 행성 상세의 저장소. **선택된 행성의 ID 로 만든다** —
|
||
/// 고정 저장소를 쓰면 어느 행성을 눌러도 같은 수치가 떠서, 갤럭시 HUD 의 FPI 와
|
||
/// 상세의 TOTAL FPI 가 어긋난다(발주사 검토회신서 3·4절).
|
||
final PlanetRepositoryFactory planetRepository;
|
||
|
||
/// 공동 Shake 방어 구독. ZAP은 GalaxyRepository의 기여 원장 경로만 쓴다.
|
||
final EclipseRepository eclipse;
|
||
|
||
@override
|
||
State<GalaxyScreen> createState() => _GalaxyScreenState();
|
||
}
|
||
|
||
class _GalaxyScreenState extends State<GalaxyScreen>
|
||
with TickerProviderStateMixin {
|
||
late Future<GalaxySnapshot> _snapshot;
|
||
|
||
final _clock = GalaxyClock();
|
||
final _view = TransformationController();
|
||
late final Ticker _ticker;
|
||
|
||
/// 중심 전환 — 0→1 을 [GalaxyMotion.recenter] 동안 흐른다.
|
||
late final AnimationController _fly;
|
||
|
||
Duration _lastTick = Duration.zero;
|
||
Matrix4 _flyFrom = Matrix4.identity();
|
||
|
||
PlanetNode? _selected;
|
||
int _selectedIndex = 0;
|
||
|
||
/// Ranking View(원장 B-21). 끄면 기본 보기 — 내 행성 중심.
|
||
bool _ranking = false;
|
||
|
||
/// 보기를 바꾸면 **새 중심 행성으로 날아간다** — 「Ranking View 전환 시 FPI 상위 중심으로
|
||
/// 이동」(B-21 검증 기준). 이전 선택은 이전 보기의 좌표라 버린다.
|
||
void _toggleRanking(GalaxySnapshot base) {
|
||
setState(() {
|
||
_ranking = !_ranking;
|
||
_selected = null;
|
||
});
|
||
final view = _ranking ? base.rankingView : base;
|
||
final i = view.planets.indexWhere((p) => p.isCenter);
|
||
if (i >= 0 && !_viewport.isEmpty) _center(view.planets[i], i, _viewport);
|
||
}
|
||
|
||
/// 맵의 가장 낮은 순위 — 궤도 간격이 여기서 나온다([GalaxyField.ringOf]).
|
||
int _outerRank = 1;
|
||
Size _viewport = Size.zero;
|
||
|
||
late final ZapController _zap;
|
||
|
||
void _onZapChanged() => setState(() {});
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_snapshot = widget.repository.load();
|
||
_zap = ZapController(widget.repository, onFailure: _notify)..addListener(_onZapChanged);
|
||
_fly = AnimationController(vsync: this, duration: GalaxyMotion.recenter)
|
||
..addListener(_onFly);
|
||
_ticker = createTicker(_tick);
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
if (MediaQuery.disableAnimationsOf(context)) {
|
||
_ticker.stop();
|
||
if (_fly.isAnimating) {
|
||
_fly.stop();
|
||
if (_selected != null && !_viewport.isEmpty) {
|
||
_view.value = _targetFor(_selected!, _selectedIndex, _viewport);
|
||
}
|
||
}
|
||
} else if (!_ticker.isActive) {
|
||
_lastTick = Duration.zero;
|
||
_ticker.start();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_zap.dispose();
|
||
_ticker.dispose();
|
||
_fly.dispose();
|
||
_clock.dispose();
|
||
_view.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _notify(String message) {
|
||
ScaffoldMessenger.of(context)
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||
);
|
||
}
|
||
|
||
void _tick(Duration now) {
|
||
final dt =
|
||
(now - _lastTick).inMicroseconds / Duration.microsecondsPerSecond;
|
||
_lastTick = now;
|
||
// 프레임을 오래 건너뛴 뒤(백그라운드 복귀 등) 한 번에 확 도는 것을 막는다.
|
||
_clock.advance(dt.clamp(0.0, 1 / 20));
|
||
}
|
||
|
||
/// 선택 행성을 화면 한가운데로 데려오는 변환.
|
||
Matrix4 _targetFor(PlanetNode p, int index, Size viewport) {
|
||
final at = GalaxyField.positionOf(p, index, _clock.spin, _outerRank);
|
||
const s = GalaxyMotion.focusZoom;
|
||
return Matrix4.identity()
|
||
..translateByDouble(
|
||
viewport.width / 2 - at.dx * s,
|
||
viewport.height / 2 - at.dy * s,
|
||
0,
|
||
1,
|
||
)
|
||
..scaleByDouble(s, s, 1, 1);
|
||
}
|
||
|
||
void _onFly() {
|
||
final p = _selected;
|
||
if (p == null || _viewport.isEmpty) return;
|
||
// 목표를 시작할 때 한 번만 잡으면, 날아가는 동안 궤도를 돈 행성은 도착
|
||
// 지점에 없다. 매 프레임 다시 잡아야 「행성 간 이동감」이 어긋나지 않는다.
|
||
_view.value = Matrix4Tween(
|
||
begin: _flyFrom,
|
||
end: _targetFor(p, _selectedIndex, _viewport),
|
||
).transform(Curves.easeOutCubic.transform(_fly.value));
|
||
}
|
||
|
||
/// 명세 — *행성 터치시 화면 중심 전환이동*.
|
||
void _center(PlanetNode p, int index, Size viewport, {bool animate = true}) {
|
||
_viewport = viewport;
|
||
setState(() {
|
||
_selected = p;
|
||
_selectedIndex = index;
|
||
});
|
||
if (!animate || MediaQuery.disableAnimationsOf(context)) {
|
||
_view.value = _targetFor(p, index, viewport);
|
||
return;
|
||
}
|
||
_flyFrom = _view.value.clone();
|
||
_fly.forward(from: 0);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => PlanetFxScope(
|
||
child: Scaffold(
|
||
body: SafeArea(
|
||
child: FutureBuilder<GalaxySnapshot>(
|
||
future: _snapshot,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError || snap.data == null) {
|
||
return Center(child: Text(context.l10n.galaxyLoadFailed));
|
||
}
|
||
// 궤도 간격은 행성 수가 정한다. _targetFor 는 목록을 들고 있지 않으므로
|
||
// 스냅샷이 도착한 여기서 한 번 잡아 둔다.
|
||
_outerRank = GalaxyField.outerRankOf(snap.data!.planets);
|
||
final data = _ranking ? snap.data!.rankingView : snap.data!;
|
||
final myFandomId = data.mine?.id;
|
||
// 이클립스는 **화면당 한 번만 구독한다.** 배너·HUD·ZAP 버튼이 각자
|
||
// 구독하면 같은 집계를 세 번 읽고, 세 값이 프레임 단위로 어긋난다.
|
||
return LiveStreamBuilder<EclipseState?>(
|
||
key: ValueKey('eclipse-${myFandomId ?? 'mine'}'),
|
||
create: () => widget.eclipse.watch(fandomId: myFandomId),
|
||
builder: (context, eclipse) => LiveStreamBuilder<PlanetVisualState>(
|
||
key: ValueKey(('galaxy-orbit', (_selected ?? data.mine)?.id)),
|
||
create: () => widget.watchPlanet((_selected ?? data.mine)?.id ?? ''),
|
||
builder: (context, state) {
|
||
_clock.idleSpeedScale = state?.isHibernating == true
|
||
? 0.25
|
||
: state?.isEclipsed == true
|
||
? 0.5
|
||
: state?.isHeroTime == true
|
||
? 1.5
|
||
: 1;
|
||
return _Map(
|
||
data: data,
|
||
watchPlanet: widget.watchPlanet,
|
||
eclipse: eclipse,
|
||
eclipseRepository: widget.eclipse,
|
||
repository: widget.repository,
|
||
planetRepository: widget.planetRepository,
|
||
clock: _clock,
|
||
view: _view,
|
||
selected: _selected ?? data.mine,
|
||
selectedIndex: _selected == null
|
||
? data.planets.indexWhere((p) => p.isMine).clamp(0, 1 << 30)
|
||
: _selectedIndex,
|
||
balance: _zap.balance,
|
||
pending: _zap.pending,
|
||
sending: _zap.sending,
|
||
touchSerial: _zap.touchSerial,
|
||
zapResult: _zap.result,
|
||
retryZap: _zap.needsRetry ? _zap.retry : null,
|
||
outerRank: _outerRank,
|
||
ranking: _ranking,
|
||
onToggleRanking: () => _toggleRanking(snap.data!),
|
||
fly: _fly,
|
||
onSelect: _center,
|
||
onDrag: _clock.drag,
|
||
onGrab: () {
|
||
// 날아가는 도중에 손을 대면 사용자가 이긴다.
|
||
if (_fly.isAnimating) _fly.stop();
|
||
},
|
||
onZap: _zap.tap,
|
||
);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _Map extends StatelessWidget {
|
||
final PlanetWatch watchPlanet;
|
||
const _Map({
|
||
required this.watchPlanet,
|
||
required this.data,
|
||
required this.eclipse,
|
||
required this.eclipseRepository,
|
||
required this.repository,
|
||
required this.planetRepository,
|
||
required this.clock,
|
||
required this.view,
|
||
required this.selected,
|
||
required this.selectedIndex,
|
||
required this.balance,
|
||
required this.pending,
|
||
required this.sending,
|
||
required this.touchSerial,
|
||
required this.zapResult,
|
||
required this.retryZap,
|
||
required this.outerRank,
|
||
required this.ranking,
|
||
required this.onToggleRanking,
|
||
required this.fly,
|
||
required this.onSelect,
|
||
required this.onDrag,
|
||
required this.onGrab,
|
||
required this.onZap,
|
||
});
|
||
|
||
final GalaxySnapshot data;
|
||
|
||
/// 활성 이클립스. null 이면 발동 중이 아니라 ZAP 을 보낼 곳이 없다.
|
||
final EclipseState? eclipse;
|
||
|
||
/// 라이브 소셜 프루프 구독에 쓴다. 방어 호출은 [onZap] 이 맡는다.
|
||
final EclipseRepository eclipseRepository;
|
||
|
||
final GalaxyRepository repository;
|
||
final PlanetRepositoryFactory planetRepository;
|
||
final GalaxyClock clock;
|
||
final TransformationController view;
|
||
final PlanetNode? selected;
|
||
final int selectedIndex;
|
||
|
||
/// 서버가 확정한 잔액. null 이면 스냅샷 값을 쓴다.
|
||
final int? balance;
|
||
|
||
/// 아직 서버에 보내지 않은 연타 수. 표시 잔액에서만 미리 뺀다.
|
||
final int pending;
|
||
final bool sending;
|
||
final int touchSerial;
|
||
final ZapResult? zapResult;
|
||
final VoidCallback? retryZap;
|
||
|
||
/// 맵의 가장 낮은 순위 — 궤도 간격을 정한다. 매 프레임 도는 [AnimatedBuilder]
|
||
/// 안에서 다시 세지 않도록 위에서 한 번 잡아 내려받는다.
|
||
final int outerRank;
|
||
|
||
/// Ranking View 켜짐 여부와 스위치.
|
||
final bool ranking;
|
||
final VoidCallback onToggleRanking;
|
||
|
||
/// 중심 전환의 진행. HUD 가 비행 중인지 알아야 위/아래를 붙들 수 있다.
|
||
final Animation<double> fly;
|
||
final void Function(PlanetNode, int, Size, {bool animate}) onSelect;
|
||
final ValueChanged<double> onDrag;
|
||
final VoidCallback onGrab;
|
||
final ValueChanged<String> onZap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => LayoutBuilder(
|
||
builder: (context, box) {
|
||
final viewport = Size(box.maxWidth, box.maxHeight);
|
||
// 명세 「나의 행성 한가운데」 — 첫 레이아웃에서 한 번 맞춘다. 사용자가
|
||
// 움직인 뒤(항등 행렬이 아님)에는 손대지 않는다. 첫 진입은 날아갈 곳이
|
||
// 없으므로 애니메이션 없이 바로 앉힌다.
|
||
if (view.value == Matrix4.identity()) {
|
||
final mine = data.planets.indexWhere((p) => p.isMine);
|
||
if (mine >= 0) {
|
||
WidgetsBinding.instance.addPostFrameCallback(
|
||
(_) => onSelect(data.planets[mine], mine, viewport, animate: false),
|
||
);
|
||
}
|
||
}
|
||
final fpiMax = data.planets.fold<int>(1, (m, p) => p.fpi > m ? p.fpi : m);
|
||
|
||
return Stack(
|
||
children: [
|
||
// 별밭은 맵보다 덜 움직인다 — 시차가 공간의 깊이를 만든다.
|
||
Positioned.fill(
|
||
child: AnimatedBuilder(
|
||
animation: view,
|
||
builder: (context, _) => LivePlanetLighting(
|
||
fandomId: selected?.id ?? data.mine?.id ?? '',
|
||
watch: watchPlanet,
|
||
child: GalaxyStarfield(view: view.value),
|
||
),
|
||
),
|
||
),
|
||
|
||
Positioned.fill(
|
||
child: InteractiveViewer(
|
||
transformationController: view,
|
||
// 맵은 화면보다 크다(900×900). 기본값(constrained)이면 자식이
|
||
// 화면 폭으로 눌려 오른쪽이 잘리고 내 행성이 가장자리에 걸린다.
|
||
constrained: false,
|
||
minScale: GalaxyMotion.minZoom,
|
||
maxScale: GalaxyMotion.maxZoom,
|
||
boundaryMargin: const EdgeInsets.all(240),
|
||
// 이동(팬)의 관성은 InteractiveViewer 의 플링이 이미 준다.
|
||
// 여기서 더하는 것은 **회전 관성**이다 — 가로로 끌면 궤도가 따라
|
||
// 돌고, 손을 떼면 마찰이 기본 표류로 되돌린다.
|
||
onInteractionStart: (_) => onGrab(),
|
||
// 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다.
|
||
onInteractionUpdate: (d) => onDrag(
|
||
GalaxyMotion.spinDragOf(d.pointerCount, d.focalPointDelta),
|
||
),
|
||
child: AnimatedBuilder(
|
||
animation: clock,
|
||
builder: (context, _) => GalaxyField(
|
||
onOpenHistory: (planet) => Navigator.of(context).push(MaterialPageRoute<void>(
|
||
builder: (_) => PlanetHistoryScreen(repository: planetRepository(planet.id), fandomName: planet.name),
|
||
)),
|
||
planets: data.planets,
|
||
watchPlanet: watchPlanet,
|
||
selected: selected,
|
||
spin: clock.spin,
|
||
pulse: clock.pulse,
|
||
onSelect: (p) =>
|
||
onSelect(p, data.planets.indexOf(p), viewport),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// HUD 는 화면이 아니라 **행성**에 붙는다. 검토회신서 3절 —
|
||
// *「선택 행성과 해당 정보가 시각적으로 하나의 정보군으로 인지될 것」*.
|
||
if (selected != null)
|
||
Positioned.fill(
|
||
child: AnimatedBuilder(
|
||
animation: Listenable.merge([view, clock]),
|
||
// 카드 내용은 선택이 바뀔 때만 달라진다. child 로 넘겨 두면
|
||
// 맵이 움직이는 매 프레임 카드를 다시 짓지 않는다.
|
||
child: _HudCard(
|
||
planet: selected!,
|
||
watchPlanet: watchPlanet,
|
||
fpiMax: fpiMax,
|
||
planetRepository: planetRepository,
|
||
// 구독한 이클립스가 **이 행성의 것일 때만** 침식을 띄운다.
|
||
// 다른 행성을 골랐을 때 내 팬덤의 침식률을 그리면 그 행성이
|
||
// 침식되고 있다고 읽힌다.
|
||
eclipse: eclipse?.fandomId == selected!.id ? eclipse : null,
|
||
eclipseRepository: eclipseRepository,
|
||
),
|
||
builder: (context, card) => _PlanetHud(
|
||
planet: selected!,
|
||
index: selectedIndex,
|
||
spin: clock.spin,
|
||
outerRank: outerRank,
|
||
view: view.value,
|
||
viewport: viewport,
|
||
flying: fly.isAnimating,
|
||
card: card!,
|
||
),
|
||
),
|
||
),
|
||
|
||
// 섬광 배너 — `fandoms/{id}/liveEvents` 를 구독해 다른 사용자의 방어
|
||
// 참여를 흘린다(별첨 1 ① 시나리오 5). 라이브 이벤트가 없으면 기존
|
||
// `galaxyEvents` 한 건으로 물러난다.
|
||
Positioned(
|
||
bottom: data.joined ? 144 : AppSpace.s4,
|
||
left: 0,
|
||
right: 0,
|
||
child: _LiveFlash(
|
||
repository: eclipseRepository,
|
||
fandomId: eclipse?.fandomId ?? data.mine?.id,
|
||
fallback: data.event,
|
||
),
|
||
),
|
||
|
||
// 명세 — GALAXY 텍스트는 좌측 하단, 돋보기는 우측 하단.
|
||
Positioned(
|
||
left: AppSpace.s4,
|
||
bottom: AppSpace.s4,
|
||
child: Text(
|
||
'GALAXY',
|
||
style: Theme.of(context).textTheme.labelMedium,
|
||
),
|
||
),
|
||
// Ranking View 스위치 — 사본에 그림이 없어 돋보기와 같은 버튼을 옆에 둔다.
|
||
Positioned(
|
||
right: AppSpace.s4 + 48 + AppSpace.s2,
|
||
bottom: AppSpace.s4,
|
||
child: IconButton.filledTonal(
|
||
tooltip: 'Ranking View',
|
||
isSelected: ranking,
|
||
onPressed: onToggleRanking,
|
||
icon: const Icon(Icons.leaderboard_outlined),
|
||
selectedIcon: const Icon(Icons.leaderboard),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: AppSpace.s4,
|
||
bottom: AppSpace.s4,
|
||
child: IconButton.filledTonal(
|
||
tooltip: context.l10n.commonSearch,
|
||
onPressed: () => Navigator.of(context, rootNavigator: true).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => GalaxySearchScreen(repository: repository),
|
||
),
|
||
),
|
||
icon: const Icon(Icons.search),
|
||
),
|
||
),
|
||
|
||
// 진행 중 이벤트를 상단에 모아 서로 겹치지 않게 한다.
|
||
Positioned(
|
||
top: AppSpace.s2,
|
||
left: AppSpace.s4,
|
||
right: AppSpace.s4,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (eclipse case final e?)
|
||
EclipseBannerCard(
|
||
state: e,
|
||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
|
||
repository: eclipseRepository, fandomId: e.fandomId,
|
||
)),
|
||
),
|
||
),
|
||
if (selected case final target?)
|
||
HeroTimeBillboard(
|
||
fandomId: target.id,
|
||
fandomName: target.name,
|
||
watch: watchPlanet,
|
||
onOpen: () => Navigator.of(context, rootNavigator: true).push(
|
||
MaterialPageRoute<void>(builder: (_) => PlanetDetailScreen(
|
||
repository: planetRepository(target.id),
|
||
watchPlanet: watchPlanet,
|
||
eclipse: eclipseRepository,
|
||
)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
if (data.mine case final mine?)
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: AnimatedBuilder(
|
||
animation: Listenable.merge([view, clock]),
|
||
builder: (context, _) => ZapFeedback(
|
||
touchSerial: touchSerial,
|
||
pending: pending > 0 || sending,
|
||
result: zapResult,
|
||
source: Offset(
|
||
viewport.width / 2,
|
||
viewport.height -
|
||
(AppSpace.s4 + 40 + AppSpace.s3) -
|
||
AppSizing.touchMinApp / 2,
|
||
),
|
||
target: MatrixUtils.transformPoint(
|
||
view.value,
|
||
GalaxyField.positionOf(
|
||
mine,
|
||
data.planets.indexOf(mine),
|
||
clock.spin,
|
||
outerRank,
|
||
),
|
||
),
|
||
radius:
|
||
GalaxyField.radiusOf(mine) *
|
||
view.value.getMaxScaleOnAxis(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (retryZap != null)
|
||
Positioned(
|
||
left: AppSpace.s4,
|
||
right: AppSpace.s4,
|
||
bottom: 200,
|
||
child: Center(
|
||
child: FilledButton.tonal(
|
||
onPressed: sending ? null : retryZap,
|
||
child: Text(context.l10n.commonRecheck),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 가입·생성한 사용자에게만 Zap 을 준다.
|
||
if (data.joined)
|
||
Positioned(
|
||
left: AppSpace.s4,
|
||
right: AppSpace.s4,
|
||
// 하단의 GALAXY 라벨·돋보기(40) 위에 놓는다.
|
||
bottom: AppSpace.s4 + 40 + AppSpace.s3,
|
||
child: LiveStreamBuilder<PlanetVisualState>(
|
||
key: ValueKey(('zap-mode', data.mine?.id)),
|
||
create: () => watchPlanet(data.mine?.id ?? ''),
|
||
builder: (context, state) {
|
||
final hero = state?.isHeroTime == true &&
|
||
state?.heroEndsAt?.isAfter(DateTime.now()) == true &&
|
||
pending == 0 && !sending && retryZap == null;
|
||
return ZapButton(
|
||
isHeroTime: hero,
|
||
// 단가는 `config/policy.zapPop` 이다 — Figma 「Zap 버튼」이
|
||
// 어드민 설정값이라 못 박았다.
|
||
zapPop: data.zapPop,
|
||
// 표시 잔액 = 서버 확정값(없으면 스냅샷) − 아직 안 보낸 연타분.
|
||
myPop: (balance ?? data.myPop) - pending * data.zapPop,
|
||
// **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는
|
||
// 「흔들어 방어」 화면에서 POP 없이 참여한다.
|
||
blockedReason: sending
|
||
? context.l10n.zapApplying
|
||
: retryZap != null
|
||
? context.l10n.zapNeedsCheck
|
||
: data.mine == null
|
||
? context.l10n.zapNoPlanet
|
||
: null,
|
||
onZap: () {
|
||
if (data.mine case final mine?) {
|
||
if (hero) {
|
||
showModalBottomSheet<void>(
|
||
context: context, isScrollControlled: true,
|
||
builder: (_) => HeroParticipationSheet(
|
||
fandomId: mine.id, watch: watchPlanet,
|
||
repository: planetRepository(mine.id),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
onSelect(
|
||
mine,
|
||
data.planets.indexOf(mine),
|
||
viewport,
|
||
animate: !MediaQuery.disableAnimationsOf(context),
|
||
);
|
||
onZap(mine.id);
|
||
}
|
||
},
|
||
onContribute: () => _todo(context, context.l10n.commonContribute),
|
||
onItem: () => _todo(context, context.l10n.commonItem),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 선택 행성에 붙어 다니는 HUD — 명세 *Rank, FPI 총점, 접속자수(맴버수)* ·
|
||
/// *HUD - 행성 상단/하단* · *터치시 FPI 상세 페이지 이동*.
|
||
///
|
||
/// 발주사 프로토타입(`FANUZU_HUD Proto.html`)에서 HUD 는 행성 위에 떠 있는
|
||
/// 스프라이트(`hud.position.y = radius + 5`)다. 화면 최상단 고정 패널이 아니다.
|
||
/// 여기서는 행성의 장면 좌표를 현재 뷰 변환으로 화면 좌표에 옮겨 그 위에 카드를
|
||
/// 세우고, 카드와 행성을 선으로 잇는다.
|
||
class _PlanetHud extends StatefulWidget {
|
||
const _PlanetHud({
|
||
required this.planet,
|
||
required this.index,
|
||
required this.spin,
|
||
required this.outerRank,
|
||
required this.view,
|
||
required this.viewport,
|
||
required this.flying,
|
||
required this.card,
|
||
});
|
||
|
||
final PlanetNode planet;
|
||
final int index;
|
||
final double spin;
|
||
final int outerRank;
|
||
final Matrix4 view;
|
||
final Size viewport;
|
||
|
||
/// 중심 전환이 진행 중인가. 진행 중에는 위/아래를 다시 정하지 않는다.
|
||
final bool flying;
|
||
final Widget card;
|
||
|
||
@override
|
||
State<_PlanetHud> createState() => _PlanetHudState();
|
||
}
|
||
|
||
class _PlanetHudState extends State<_PlanetHud> {
|
||
/// 카드가 행성 위에 서는가. **비행 중에는 붙들어 둔다** — 화면 좌표가
|
||
/// 매 프레임 크게 움직이므로 그때그때 다시 정하면 HUD 가 위아래로 튄다.
|
||
bool? _above;
|
||
|
||
@override
|
||
void didUpdateWidget(_PlanetHud old) {
|
||
super.didUpdateWidget(old);
|
||
// 행성이 바뀌면 새로 정한다 — 다음 프레임(비행 첫 프레임)의 값으로 굳는다.
|
||
if (old.planet.id != widget.planet.id) _above = null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final planet = widget.planet;
|
||
final index = widget.index;
|
||
final spin = widget.spin;
|
||
final viewport = widget.viewport;
|
||
final at = GalaxyField.positionOf(planet, index, spin, widget.outerRank);
|
||
final screen = MatrixUtils.transformPoint(widget.view, at);
|
||
final zoom = widget.view.getMaxScaleOnAxis();
|
||
// 일러스트는 반지름의 3.2배 상자에 그려지므로 눈에 보이는 반지름은 1.6배다.
|
||
final r =
|
||
GalaxyField.screenRadiusOf(planet, index, spin, widget.outerRank) *
|
||
1.6 *
|
||
zoom;
|
||
|
||
// 행성이 화면 밖이면 HUD 도 없다. 카드만 가장자리에 clamp 해 두면 어느
|
||
// 행성의 정보인지 알 수 없고, 연결선은 화면 밖 좌표에 그려진다.
|
||
if (screen.dx < -r ||
|
||
screen.dx > viewport.width + r ||
|
||
screen.dy < -r ||
|
||
screen.dy > viewport.height + r) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
const w = GalaxyMotion.hudWidth;
|
||
const gap = GalaxyMotion.hudGap;
|
||
const margin = GalaxyMotion.hudMargin;
|
||
if (_above == null || !widget.flying) {
|
||
_above = screen.dy - r - gap - GalaxyMotion.hudHeightHint > margin;
|
||
}
|
||
final above = _above!;
|
||
|
||
final left = (screen.dx - w / 2).clamp(
|
||
margin,
|
||
(viewport.width - w - margin).clamp(margin, double.infinity),
|
||
);
|
||
final stemTop = above ? screen.dy - r - gap : screen.dy + r;
|
||
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// 행성과 카드를 잇는 선 — 「하나의 정보군」으로 읽히게 하는 장치다.
|
||
Positioned(
|
||
left: screen.dx - 1,
|
||
top: stemTop,
|
||
width: 2,
|
||
height: gap,
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
// 행성에 닿는 쪽이 진하다.
|
||
colors: above
|
||
? const [Color(0x00000000), AppColors.cosmicAccent]
|
||
: const [AppColors.cosmicAccent, Color(0x00000000)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
left: left,
|
||
width: w,
|
||
top: above ? null : screen.dy + r + gap,
|
||
bottom: above ? viewport.height - (screen.dy - r - gap) : null,
|
||
child: widget.card,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HudCard extends StatelessWidget {
|
||
final PlanetWatch watchPlanet;
|
||
const _HudCard({
|
||
required this.watchPlanet,
|
||
required this.planet,
|
||
required this.fpiMax,
|
||
required this.planetRepository,
|
||
required this.eclipse,
|
||
required this.eclipseRepository,
|
||
});
|
||
|
||
final PlanetNode planet;
|
||
final int fpiMax;
|
||
|
||
/// 이 행성의 활성 이클립스. null 이면 침식 줄을 그리지 않는다.
|
||
final EclipseState? eclipse;
|
||
|
||
/// 행성 상세로 그대로 넘기는 저장소. 상세의 침식 배너가 이것을 구독한다.
|
||
final EclipseRepository eclipseRepository;
|
||
|
||
/// 눌러 들어갈 상세의 저장소를 **선택된 행성의 ID 로** 만든다.
|
||
final PlanetRepositoryFactory planetRepository;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = Theme.of(context).textTheme;
|
||
return TweenAnimationBuilder<double>(
|
||
// 선택이 바뀌면 키가 바뀌어 처음부터 다시 흐른다 — 「HUD 데이터 즉시 연동」이
|
||
// 눈에 보이게 하는 신호다. AnimatedSwitcher 를 쓰면 전환 중 HUD 가 둘이 된다.
|
||
key: ValueKey('hud-${planet.id}'),
|
||
tween: Tween(begin: 0, end: 1),
|
||
duration: const Duration(milliseconds: 260),
|
||
curve: Curves.easeOutCubic,
|
||
builder: (context, v, child) => Opacity(
|
||
opacity: v,
|
||
child: Transform.scale(scale: 0.92 + 0.08 * v, child: child),
|
||
),
|
||
child: Material(
|
||
color: AppColors.cosmicSurfaceContainer.withValues(alpha: 0.92),
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: InkWell(
|
||
// 명세 — HUD 터치시 FPI 상세 페이지 이동.
|
||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => PlanetDetailScreen(
|
||
repository: planetRepository(planet.id),
|
||
watchPlanet: watchPlanet,
|
||
eclipse: eclipseRepository,
|
||
),
|
||
),
|
||
),
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
border: Border.all(
|
||
color: AppColors.cosmicAccent.withValues(alpha: 0.45),
|
||
),
|
||
),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpace.s3,
|
||
vertical: AppSpace.s2,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
planet.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: t.titleSmall,
|
||
),
|
||
),
|
||
// Stage0 성운은 랭킹 모집단이 아니다(B-17).
|
||
_Metric(label: 'RANK', value: planet.stage == 0 ? '-' : '${planet.rank}'),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpace.s1),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text('FPI', style: t.labelSmall),
|
||
const SizedBox(width: AppSpace.s2),
|
||
Text(
|
||
formatPop(planet.fpi),
|
||
style: t.titleSmall?.copyWith(
|
||
color: AppColors.cosmicAccent,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpace.s1),
|
||
// FPI 게이지 — 프로토타입 HUD 우측의 막대와 같은 역할이다.
|
||
// 숫자만으로는 다른 행성과의 거리가 읽히지 않는다.
|
||
_FpiGauge(ratio: fpiMax == 0 ? 0 : planet.fpi / fpiMax),
|
||
const SizedBox(height: AppSpace.s1),
|
||
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 `erosionRate` 와
|
||
// 샤드 합이다. HUD 가 실시간으로 이 값을 따른다(스펙 B).
|
||
if (eclipse case final e?) ...[
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
e.defended
|
||
? Icons.verified_outlined
|
||
: Icons.dark_mode_outlined,
|
||
size: 12,
|
||
color: e.defended
|
||
? AppColors.neonEmerald
|
||
: AppColors.neonMagenta,
|
||
),
|
||
const SizedBox(width: AppSpace.s1),
|
||
Expanded(
|
||
child: Text(
|
||
e.defended
|
||
? context.l10n.galaxyDefended
|
||
: context.l10n.galaxyErosion(
|
||
'${e.erosionRate}',
|
||
'${(e.progress * 100).round()}',
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: t.labelSmall?.copyWith(
|
||
color: e.defended
|
||
? AppColors.neonEmerald
|
||
: AppColors.neonMagenta,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpace.s1),
|
||
],
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
context.l10n.galaxyOnlineMembers(
|
||
formatPop(planet.online),
|
||
formatPop(planet.members),
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: t.labelSmall,
|
||
),
|
||
),
|
||
const Icon(
|
||
Icons.chevron_right,
|
||
size: 16,
|
||
color: AppColors.onCosmicVariant,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FpiGauge extends StatelessWidget {
|
||
const _FpiGauge({required this.ratio});
|
||
|
||
final double ratio;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => ClipRRect(
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
child: LinearProgressIndicator(
|
||
value: ratio.clamp(0.0, 1.0),
|
||
minHeight: 3,
|
||
backgroundColor: AppColors.cosmicOutline.withValues(alpha: 0.4),
|
||
valueColor: const AlwaysStoppedAnimation(AppColors.cosmicAccent),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _Metric extends StatelessWidget {
|
||
const _Metric({required this.label, required this.value});
|
||
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = Theme.of(context).textTheme;
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(label, style: t.labelSmall),
|
||
const SizedBox(width: AppSpace.s1),
|
||
Text(
|
||
value,
|
||
style: t.titleSmall?.copyWith(color: AppColors.cosmicAccent),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 섬광 배너 한 자리 — 라이브 이벤트를 구독해 최신 한 건을 흘린다.
|
||
///
|
||
/// 근거: 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프가 **타 사용자
|
||
/// 화면에** 실시간 노출」. 내 방어가 아니라 **팬덤 전체의 방어**가 흘러야 하므로
|
||
/// 원천이 내 호출 결과가 아니라 서버 컬렉션이다.
|
||
///
|
||
/// 새 이벤트가 오면 문서 ID 가 바뀌어 슬라이드가 처음부터 다시 흐른다 — 명세의
|
||
/// *「우측에서 좌측으로 1회 슬라이드」* 가 이벤트마다 한 번씩 일어난다.
|
||
class _LiveFlash extends StatelessWidget {
|
||
const _LiveFlash({
|
||
required this.repository,
|
||
required this.fandomId,
|
||
required this.fallback,
|
||
});
|
||
|
||
final EclipseRepository repository;
|
||
final String? fandomId;
|
||
|
||
/// 라이브 이벤트가 없을 때 그릴 기존 `galaxyEvents` 한 건.
|
||
final GalaxyEvent? fallback;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (fandomId == null) {
|
||
return fallback == null
|
||
? const SizedBox.shrink()
|
||
: _EventTicker(
|
||
key: const ValueKey('galaxy-event'),
|
||
message: fallback!.message,
|
||
alert: fallback!.kind == GalaxyEventKind.eclipse,
|
||
);
|
||
}
|
||
return LiveStreamBuilder<List<LiveEvent>>(
|
||
key: ValueKey('flash-$fandomId'),
|
||
create: () => repository.liveEvents(fandomId: fandomId, limit: 5),
|
||
builder: (context, events) {
|
||
if (events == null || events.isEmpty) {
|
||
return fallback == null
|
||
? const SizedBox.shrink()
|
||
: _EventTicker(
|
||
key: const ValueKey('galaxy-event'),
|
||
message: fallback!.message,
|
||
alert: fallback!.kind == GalaxyEventKind.eclipse,
|
||
);
|
||
}
|
||
final latest = events.first;
|
||
return _EventTicker(
|
||
key: ValueKey('live-${latest.id}'),
|
||
message: liveEventMessage(latest),
|
||
alert: latest.kind == LiveEventKind.eclipseDefense,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 이벤트 메시지 — 명세 *디폴트 hide · 우측에서 좌측으로 1회 슬라이드*.
|
||
class _EventTicker extends StatefulWidget {
|
||
const _EventTicker({super.key, required this.message, required this.alert});
|
||
|
||
final String message;
|
||
|
||
/// 이클립스 계열인가. 색을 가른다.
|
||
final bool alert;
|
||
|
||
@override
|
||
State<_EventTicker> createState() => _EventTickerState();
|
||
}
|
||
|
||
class _EventTickerState extends State<_EventTicker>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _c = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 900),
|
||
)..forward();
|
||
|
||
@override
|
||
void dispose() {
|
||
_c.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = Theme.of(context).textTheme;
|
||
final color = widget.alert ? AppColors.error : AppColors.cosmicAccent;
|
||
return SlideTransition(
|
||
position: Tween(
|
||
begin: const Offset(1, 0),
|
||
end: Offset.zero,
|
||
).animate(CurvedAnimation(parent: _c, curve: Curves.easeOut)),
|
||
child: Center(
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpace.s3,
|
||
vertical: AppSpace.s2,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cosmicSurfaceRaised,
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
border: Border.all(color: color.withValues(alpha: 0.6)),
|
||
),
|
||
child: Text(
|
||
widget.message,
|
||
style: t.bodySmall?.copyWith(color: color),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _todo(BuildContext context, String where) {
|
||
ScaffoldMessenger.of(context)
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(
|
||
content: Text(context.l10n.commonNotYet(where)),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|