diff --git a/app/lib/features/galaxy/galaxy_data.dart b/app/lib/features/galaxy/galaxy_data.dart index 8d2158a..3f84425 100644 --- a/app/lib/features/galaxy/galaxy_data.dart +++ b/app/lib/features/galaxy/galaxy_data.dart @@ -96,8 +96,7 @@ class GalaxySnapshot { /// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼). final bool joined; - PlanetNode? get mine => - planets.where((p) => p.isMine).firstOrNull; + PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull; /// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데 /// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로 @@ -133,7 +132,11 @@ class GalaxySnapshot { /// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다. class ZapResult { - const ZapResult({required this.txId, required this.balanceAfter, this.contributedPop}); + const ZapResult({ + required this.txId, + required this.balanceAfter, + this.contributedPop, + }); final String txId; @@ -203,16 +206,73 @@ class SampleGalaxyRepository implements GalaxyRepository { 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), + 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), + PlanetNode( + id: 'bts', + name: 'BTS', + rank: 1, + fpi: 284310, + members: 12840, + online: 1204, + isMine: true, + ), + PlanetNode( + id: 'newjeans', + name: 'NewJeans', + rank: 2, + fpi: 271905, + members: 11230, + online: 980, + ), + PlanetNode( + id: 'seventeen', + name: 'SEVENTEEN', + rank: 3, + fpi: 254120, + members: 10450, + online: 872, + ), ], ); } diff --git a/app/lib/features/galaxy/galaxy_home_screen.dart b/app/lib/features/galaxy/galaxy_home_screen.dart index 2109f06..4372ff8 100644 --- a/app/lib/features/galaxy/galaxy_home_screen.dart +++ b/app/lib/features/galaxy/galaxy_home_screen.dart @@ -77,7 +77,10 @@ class _GalaxyHomeScreenState extends State { if (!ok || !mounted) return; try { await widget.repository.setPrimary(p.id); - if (mounted) setState(() { _snapshot = widget.repository.load(); }); + if (mounted) + setState(() { + _snapshot = widget.repository.load(); + }); } on BackendException catch (e) { // 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다. if (mounted) await showAlertDialog(context, e.message); @@ -101,11 +104,14 @@ class _GalaxyHomeScreenState extends State { final myPop = (await _snapshot).myPop; final fandomId = await navigator.push( MaterialPageRoute( - builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop), + builder: (_) => + NebulaCreateScreen(repository: widget.home, myPop: myPop), ), ); if (fandomId == null || !mounted) return; - setState(() { _snapshot = widget.repository.load(); }); + setState(() { + _snapshot = widget.repository.load(); + }); await navigator.push( MaterialPageRoute( builder: (_) => PlanetDetailScreen( @@ -182,6 +188,22 @@ class _GalaxyHomeScreenState extends State { MaterialPageRoute( builder: (_) => GalaxySearchScreen( repository: widget.repository, + onOpen: (p) => + Navigator.of( + context, + rootNavigator: true, + ).push( + MaterialPageRoute( + builder: (_) => + PlanetDetailScreen( + repository: widget + .planetRepository(p.id), + watchPlanet: + widget.watchPlanet, + eclipse: widget.eclipse, + ), + ), + ), ), ), ), @@ -278,9 +300,7 @@ class _GalaxyHomeScreenState extends State { left: 0, right: 0, bottom: 16, - child: Center( - child: _NebulaPill(onTap: _createNebula), - ), + child: Center(child: _NebulaPill(onTap: _createNebula)), ), ], ); diff --git a/app/lib/features/galaxy/galaxy_motion.dart b/app/lib/features/galaxy/galaxy_motion.dart index f59ab9d..123288a 100644 --- a/app/lib/features/galaxy/galaxy_motion.dart +++ b/app/lib/features/galaxy/galaxy_motion.dart @@ -132,8 +132,10 @@ class GalaxyClock extends ChangeNotifier { if (_dragged != 0) { // 손이 닿아 있는 동안의 각속도는 **드래그 거리가 아니라 드래그 속도**를 // 따른다. 거리로 누적하면 천천히 오래 끌기만 해도 팽이처럼 돌아 버린다. - _velocity = (_dragged / dt * GalaxyMotion.dragSpinGain) - .clamp(-GalaxyMotion.maxSpin, GalaxyMotion.maxSpin); + _velocity = (_dragged / dt * GalaxyMotion.dragSpinGain).clamp( + -GalaxyMotion.maxSpin, + GalaxyMotion.maxSpin, + ); _dragged = 0; } else { // 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다. diff --git a/app/lib/features/galaxy/galaxy_repository_firestore.dart b/app/lib/features/galaxy/galaxy_repository_firestore.dart index fac3214..a18456c 100644 --- a/app/lib/features/galaxy/galaxy_repository_firestore.dart +++ b/app/lib/features/galaxy/galaxy_repository_firestore.dart @@ -1,4 +1,5 @@ -import 'package:cloud_firestore/cloud_firestore.dart' show QueryDocumentSnapshot; +import 'package:cloud_firestore/cloud_firestore.dart' + show QueryDocumentSnapshot; import '../../core/data/backend.dart'; import '../planet/planet_data.dart' show rankWithTies; @@ -58,7 +59,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository { // fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다. // Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의 // 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다. - bool isNebula(QueryDocumentSnapshot> d) => d.data()['stage'] == 0; + bool isNebula(QueryDocumentSnapshot> d) => + d.data()['stage'] == 0; final formal = fandoms.where((d) => !isNebula(d)).toList(); final planets = [ for (final (doc, rank) in rankWithTies( @@ -79,7 +81,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository { ]); for (final doc in missing) { final data = doc.data(); - if (data != null) byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId); + if (data != null) + byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId); } return GalaxySnapshot( @@ -93,7 +96,12 @@ class FirestoreGalaxyRepository implements GalaxyRepository { ); } - PlanetNode _node(String id, Map data, int rank, String myFandomId) => PlanetNode( + PlanetNode _node( + String id, + Map data, + int rank, + String myFandomId, + ) => PlanetNode( id: id, stage: switch (data['stage']) { int n when n >= 0 && n <= 6 => n, @@ -124,7 +132,10 @@ class FirestoreGalaxyRepository implements GalaxyRepository { required String clientSeq, }) async { final snapshot = await _backend.db.doc('config/policy').get(); - final zapPop = (snapshot.data() ?? const {}).intOr('zapPop', 10); + final zapPop = (snapshot.data() ?? const {}).intOr( + 'zapPop', + 10, + ); final data = await _backend.call('fnPopSpend', { 'popType': 'activity', 'amount': zapPop * count, @@ -135,9 +146,17 @@ class FirestoreGalaxyRepository implements GalaxyRepository { 'refPath': 'fandoms/$fandomId', }); final lots = data['lotRefs']; - final contributedPop = lots is List && lots.isNotEmpty && lots.every( - (lot) => lot is Map && lot['delta'] is int && (lot['delta'] as int) < 0, - ) ? lots.fold(0, (sum, lot) => sum - (lot['delta'] as int)) : null; + final contributedPop = + lots is List && + lots.isNotEmpty && + lots.every( + (lot) => + lot is Map && + lot['delta'] is int && + (lot['delta'] as int) < 0, + ) + ? lots.fold(0, (sum, lot) => sum - (lot['delta'] as int)) + : null; return ZapResult( contributedPop: contributedPop, txId: data['txId'] as String? ?? '', diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart index b10a1ea..be3edd2 100644 --- a/app/lib/features/galaxy/galaxy_screen.dart +++ b/app/lib/features/galaxy/galaxy_screen.dart @@ -125,7 +125,8 @@ class _GalaxyScreenState extends State void initState() { super.initState(); _snapshot = widget.repository.load(); - _zap = ZapController(widget.repository, onFailure: _notify)..addListener(_onZapChanged); + _zap = ZapController(widget.repository, onFailure: _notify) + ..addListener(_onZapChanged); _fly = AnimationController(vsync: this, duration: GalaxyMotion.recenter) ..addListener(_onFly); _ticker = createTicker(_tick); @@ -182,12 +183,18 @@ class _GalaxyScreenState extends State void _follow() { final p = _selected; if (p == null || _viewport.isEmpty || _fly.isAnimating) return; - final at = GalaxyField.positionOf(p, _selectedIndex, _clock.spin, _outerRank); + 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) { + if ((m.storage[12] - tx).abs() < 0.01 && + (m.storage[13] - ty).abs() < 0.01) { return; } _view.value = m.clone() @@ -258,50 +265,57 @@ class _GalaxyScreenState extends State 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(); + 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, + ); }, - onZap: _zap.tap, - ); - }, - ), + ), ); }, ), @@ -433,9 +447,14 @@ class _Map extends StatelessWidget { child: AnimatedBuilder( animation: clock, builder: (context, _) => GalaxyField( - onOpenHistory: (planet) => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => PlanetHistoryScreen(repository: planetRepository(planet.id), fandomName: planet.name), - )), + onOpenHistory: (planet) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlanetHistoryScreen( + repository: planetRepository(planet.id), + fandomName: planet.name, + ), + ), + ), planets: data.planets, watchPlanet: watchPlanet, selected: selected, @@ -522,7 +541,19 @@ class _Map extends StatelessWidget { tooltip: context.l10n.commonSearch, onPressed: () => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( - builder: (_) => GalaxySearchScreen(repository: repository), + 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), @@ -540,24 +571,31 @@ class _Map extends StatelessWidget { if (eclipse case final e?) EclipseBannerCard( state: e, - onTap: () => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute(builder: (_) => EclipseDefendScreen( - repository: eclipseRepository, fandomId: e.fandomId, - )), - ), + 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, - )), - ), + onOpen: () => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + repository: planetRepository(target.id), + watchPlanet: watchPlanet, + eclipse: eclipseRepository, + ), + ), + ), ), ], ), @@ -618,48 +656,54 @@ class _Map extends StatelessWidget { key: ValueKey(('zap-mode', data.mine?.id)), create: () => watchPlanet(data.mine?.id ?? ''), builder: (context, state) { - final hero = state?.isHeroTime == true && + final hero = + state?.isHeroTime == true && state?.heroEndsAt?.isAfter(DateTime.now()) == true && - pending == 0 && !sending && retryZap == null; + 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), + 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), ); }, ), @@ -866,7 +910,10 @@ class _HudCard extends StatelessWidget { ), ), // Stage0 성운은 랭킹 모집단이 아니다(B-17). - _Metric(label: 'RANK', value: planet.stage == 0 ? '-' : '${planet.rank}'), + _Metric( + label: 'RANK', + value: planet.stage == 0 ? '-' : '${planet.rank}', + ), ], ), const SizedBox(height: AppSpace.s1), diff --git a/app/lib/features/galaxy/galaxy_search_screen.dart b/app/lib/features/galaxy/galaxy_search_screen.dart index e85fdd1..13faa23 100644 --- a/app/lib/features/galaxy/galaxy_search_screen.dart +++ b/app/lib/features/galaxy/galaxy_search_screen.dart @@ -16,10 +16,14 @@ class GalaxySearchScreen extends StatefulWidget { const GalaxySearchScreen({ super.key, this.repository = const SampleGalaxyRepository(), + this.onOpen, }); final GalaxyRepository repository; + /// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다. + final void Function(PlanetNode planet)? onOpen; + @override State createState() => _GalaxySearchScreenState(); } @@ -153,7 +157,12 @@ class _GalaxySearchScreenState extends State { padding: const EdgeInsets.all(AppSpace.s4), itemCount: hits.length, separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2), - itemBuilder: (context, i) => _Hit(planet: hits[i]), + itemBuilder: (context, i) => _Hit( + planet: hits[i], + onTap: widget.onOpen == null + ? null + : () => widget.onOpen!(hits[i]), + ), ); }, ), @@ -257,40 +266,45 @@ class _NoHit extends StatelessWidget { } class _Hit extends StatelessWidget { - const _Hit({required this.planet}); + const _Hit({required this.planet, this.onTap}); final PlanetNode planet; + final VoidCallback? onTap; @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, - ), - ], + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: 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), ), - ), - Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall), - ], + const SizedBox(width: AppSpace.s4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(planet.name, style: t.bodyMedium), + Text( + context.l10n.galaxyMembers(formatPop(planet.members)), + style: t.labelSmall, + ), + ], + ), + ), + Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall), + ], + ), ), ); } diff --git a/app/lib/features/galaxy/widgets/galaxy_field.dart b/app/lib/features/galaxy/widgets/galaxy_field.dart index dc26633..e7d040f 100644 --- a/app/lib/features/galaxy/widgets/galaxy_field.dart +++ b/app/lib/features/galaxy/widgets/galaxy_field.dart @@ -73,12 +73,20 @@ class GalaxyField extends StatelessWidget { /// 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); + 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) { + 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); @@ -101,7 +109,10 @@ class GalaxyField extends StatelessWidget { /// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이. static double depthOf(Offset at) => - ((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp(-1.0, 1.0); + ((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp( + -1.0, + 1.0, + ); /// 2.5D 깊이 배율. 궤도 앞쪽일수록 크다. static double depthScaleOf(Offset at) => @@ -125,7 +136,9 @@ class GalaxyField extends StatelessWidget { /// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 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'); + : (index.isEven + ? 'assets/figma/planet-1.png' + : 'assets/figma/planet-2.png'); @override Widget build(BuildContext context) { @@ -147,7 +160,10 @@ class GalaxyField extends StatelessWidget { Positioned.fill( child: RepaintBoundary( child: CustomPaint( - painter: _BackdropPainter(planets: planets, outerRank: outerRank), + painter: _BackdropPainter( + planets: planets, + outerRank: outerRank, + ), isComplex: true, willChange: false, ), @@ -169,7 +185,9 @@ class GalaxyField extends StatelessWidget { opacity: depthOpacityOf(at), selected: selected?.id == p.id, onTap: () => onSelect(p), - onOpenHistory: onOpenHistory == null ? null : () => onOpenHistory!(p), + onOpenHistory: onOpenHistory == null + ? null + : () => onOpenHistory!(p), ), ], ), @@ -243,8 +261,17 @@ class _PlanetHit extends StatelessWidget { ), ), // 3~5. 본체 일러스트(표면 장식·구름 포함). - LivePlanet25D(onOpenHistory: onOpenHistory, fandomId: planet.id, watch: watchPlanet, image: image, size: side, stage: planet.stage, glow: glow, - animate: selected, lowEffects: !selected), + 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( @@ -257,7 +284,9 @@ class _PlanetHit extends StatelessWidget { fontWeight: planet.isMine ? FontWeight.w600 : FontWeight.w400, - shadows: const [Shadow(color: Colors.black, blurRadius: 6)], + shadows: const [ + Shadow(color: Colors.black, blurRadius: 6), + ], ), ), ), @@ -309,7 +338,8 @@ class _BackdropPainter extends CustomPainter { static bool _sameRanks(List a, List b) { for (var i = 0; i < a.length; i++) { - if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter) return false; + if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter) + return false; } return true; } diff --git a/app/lib/features/galaxy/widgets/galaxy_web_view.dart b/app/lib/features/galaxy/widgets/galaxy_web_view.dart index 66123a7..171d9d7 100644 --- a/app/lib/features/galaxy/widgets/galaxy_web_view.dart +++ b/app/lib/features/galaxy/widgets/galaxy_web_view.dart @@ -148,7 +148,8 @@ class _GalaxyWebViewState extends State } bool get _visible { - if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) return false; + if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) + return false; if (ModalRoute.of(context)?.isCurrent == false) return false; final box = context.findRenderObject(); if (box is! RenderBox || !box.attached || !box.hasSize) return true; diff --git a/app/lib/features/galaxy/widgets/zap_button.dart b/app/lib/features/galaxy/widgets/zap_button.dart index 041274f..e55ee8a 100644 --- a/app/lib/features/galaxy/widgets/zap_button.dart +++ b/app/lib/features/galaxy/widgets/zap_button.dart @@ -80,7 +80,8 @@ class _ZapButtonState extends State { } bool get _canZap => - widget.blockedReason == null && (widget.isHeroTime || widget.myPop >= widget.zapPop); + widget.blockedReason == null && + (widget.isHeroTime || widget.myPop >= widget.zapPop); @override Widget build(BuildContext context) => AnimatedSize( @@ -180,7 +181,9 @@ class _ZapButtonState extends State { icon: const Icon(Icons.bolt), label: Text( _canZap - ? widget.isHeroTime ? 'ZAP+ · HERO TIME' : 'ZAP ${widget.zapPop} POP' + ? widget.isHeroTime + ? 'ZAP+ · HERO TIME' + : 'ZAP ${widget.zapPop} POP' : (widget.blockedReason ?? context.l10n.zapNotEnoughPop), ), ), diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index 49c9a84..8c55c7c 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -447,6 +447,24 @@ class _HomeBody extends StatelessWidget { MaterialPageRoute( builder: (_) => GalaxySearchScreen( repository: zap!.repository, + onOpen: (p) => + Navigator.of( + context, + rootNavigator: true, + ).push( + MaterialPageRoute( + builder: (_) => + PlanetDetailScreen( + repository: + planetRepository!( + p.id, + ), + watchPlanet: + watchPlanet, + eclipse: eclipse, + ), + ), + ), ), ), ), diff --git a/app/test/f2_screens_test.dart b/app/test/f2_screens_test.dart index a01919b..4c3e4fc 100644 --- a/app/test/f2_screens_test.dart +++ b/app/test/f2_screens_test.dart @@ -471,6 +471,15 @@ void main() { expect(find.text('전체 삭제'), findsOneWidget); }); + testWidgets('결과 줄을 누르면 그 행성 상세로 간다(10/5 실기기)', (tester) async { + PlanetNode? opened; + await _pump(tester, GalaxySearchScreen(onOpen: (p) => opened = p)); + await tester.enterText(find.byType(TextField), 'BTS'); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('멤버 12,840명')); + expect(opened?.name, 'BTS'); + }); + testWidgets('없는 이름이면 사유를 알린다', (tester) async { await _pump(tester, const GalaxySearchScreen()); await tester.enterText(find.byType(TextField), 'zzzz');