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