갤럭시 검색 결과에서 행성 상세로 이동

This commit is contained in:
Sn-Kinos
2026-10-05 17:39:23 +09:00
parent 54d0b23260
commit 8a1b824307
11 changed files with 394 additions and 171 deletions
+71 -11
View File
@@ -96,8 +96,7 @@ class GalaxySnapshot {
/// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼). /// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼).
final bool joined; final bool joined;
PlanetNode? get mine => PlanetNode? get mine => planets.where((p) => p.isMine).firstOrNull;
planets.where((p) => p.isMine).firstOrNull;
/// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데 /// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
/// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로 /// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로
@@ -133,7 +132,11 @@ class GalaxySnapshot {
/// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다. /// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다.
class ZapResult { 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; final String txId;
@@ -203,16 +206,73 @@ class SampleGalaxyRepository implements GalaxyRepository {
online: 1204, online: 1204,
isMine: true, isMine: true,
), ),
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980), PlanetNode(
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872), id: 'newjeans',
PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 198740, members: 8320, online: 611), name: 'NewJeans',
PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 176250, members: 7640, online: 540), rank: 2,
PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 152080, members: 6910, online: 402), 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 [ memberships: const [
PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 1204, isMine: true), PlanetNode(
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 271905, members: 11230, online: 980), id: 'bts',
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 254120, members: 10450, online: 872), 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,
),
], ],
); );
} }
@@ -77,7 +77,10 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
if (!ok || !mounted) return; if (!ok || !mounted) return;
try { try {
await widget.repository.setPrimary(p.id); await widget.repository.setPrimary(p.id);
if (mounted) setState(() { _snapshot = widget.repository.load(); }); if (mounted)
setState(() {
_snapshot = widget.repository.load();
});
} on BackendException catch (e) { } on BackendException catch (e) {
// 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다. // 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다.
if (mounted) await showAlertDialog(context, e.message); if (mounted) await showAlertDialog(context, e.message);
@@ -101,11 +104,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
final myPop = (await _snapshot).myPop; final myPop = (await _snapshot).myPop;
final fandomId = await navigator.push( final fandomId = await navigator.push(
MaterialPageRoute<String>( MaterialPageRoute<String>(
builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop), builder: (_) =>
NebulaCreateScreen(repository: widget.home, myPop: myPop),
), ),
); );
if (fandomId == null || !mounted) return; if (fandomId == null || !mounted) return;
setState(() { _snapshot = widget.repository.load(); }); setState(() {
_snapshot = widget.repository.load();
});
await navigator.push( await navigator.push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen( builder: (_) => PlanetDetailScreen(
@@ -182,6 +188,22 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen( builder: (_) => GalaxySearchScreen(
repository: widget.repository, repository: widget.repository,
onOpen: (p) =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) =>
PlanetDetailScreen(
repository: widget
.planetRepository(p.id),
watchPlanet:
widget.watchPlanet,
eclipse: widget.eclipse,
),
),
),
), ),
), ),
), ),
@@ -278,9 +300,7 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 16, bottom: 16,
child: Center( child: Center(child: _NebulaPill(onTap: _createNebula)),
child: _NebulaPill(onTap: _createNebula),
),
), ),
], ],
); );
+4 -2
View File
@@ -132,8 +132,10 @@ class GalaxyClock extends ChangeNotifier {
if (_dragged != 0) { if (_dragged != 0) {
// 손이 닿아 있는 동안의 각속도는 **드래그 거리가 아니라 드래그 속도**를 // 손이 닿아 있는 동안의 각속도는 **드래그 거리가 아니라 드래그 속도**를
// 따른다. 거리로 누적하면 천천히 오래 끌기만 해도 팽이처럼 돌아 버린다. // 따른다. 거리로 누적하면 천천히 오래 끌기만 해도 팽이처럼 돌아 버린다.
_velocity = (_dragged / dt * GalaxyMotion.dragSpinGain) _velocity = (_dragged / dt * GalaxyMotion.dragSpinGain).clamp(
.clamp(-GalaxyMotion.maxSpin, GalaxyMotion.maxSpin); -GalaxyMotion.maxSpin,
GalaxyMotion.maxSpin,
);
_dragged = 0; _dragged = 0;
} else { } else {
// 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다. // 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다.
@@ -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 '../../core/data/backend.dart';
import '../planet/planet_data.dart' show rankWithTies; import '../planet/planet_data.dart' show rankWithTies;
@@ -58,7 +59,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다. // fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
// Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의 // Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의
// 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다. // 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다.
bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) => d.data()['stage'] == 0; bool isNebula(QueryDocumentSnapshot<Map<String, dynamic>> d) =>
d.data()['stage'] == 0;
final formal = fandoms.where((d) => !isNebula(d)).toList(); final formal = fandoms.where((d) => !isNebula(d)).toList();
final planets = <PlanetNode>[ final planets = <PlanetNode>[
for (final (doc, rank) in rankWithTies( for (final (doc, rank) in rankWithTies(
@@ -79,7 +81,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
]); ]);
for (final doc in missing) { for (final doc in missing) {
final data = doc.data(); 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( return GalaxySnapshot(
@@ -93,7 +96,12 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
); );
} }
PlanetNode _node(String id, Map<String, dynamic> data, int rank, String myFandomId) => PlanetNode( PlanetNode _node(
String id,
Map<String, dynamic> data,
int rank,
String myFandomId,
) => PlanetNode(
id: id, id: id,
stage: switch (data['stage']) { stage: switch (data['stage']) {
int n when n >= 0 && n <= 6 => n, int n when n >= 0 && n <= 6 => n,
@@ -124,7 +132,10 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
required String clientSeq, required String clientSeq,
}) async { }) async {
final snapshot = await _backend.db.doc('config/policy').get(); final snapshot = await _backend.db.doc('config/policy').get();
final zapPop = (snapshot.data() ?? const <String, dynamic>{}).intOr('zapPop', 10); final zapPop = (snapshot.data() ?? const <String, dynamic>{}).intOr(
'zapPop',
10,
);
final data = await _backend.call('fnPopSpend', { final data = await _backend.call('fnPopSpend', {
'popType': 'activity', 'popType': 'activity',
'amount': zapPop * count, 'amount': zapPop * count,
@@ -135,9 +146,17 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
'refPath': 'fandoms/$fandomId', 'refPath': 'fandoms/$fandomId',
}); });
final lots = data['lotRefs']; final lots = data['lotRefs'];
final contributedPop = lots is List && lots.isNotEmpty && lots.every( final contributedPop =
(lot) => lot is Map && lot['delta'] is int && (lot['delta'] as int) < 0, lots is List &&
) ? lots.fold<int>(0, (sum, lot) => sum - (lot['delta'] as int)) : null; 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( return ZapResult(
contributedPop: contributedPop, contributedPop: contributedPop,
txId: data['txId'] as String? ?? '', txId: data['txId'] as String? ?? '',
+71 -24
View File
@@ -125,7 +125,8 @@ class _GalaxyScreenState extends State<GalaxyScreen>
void initState() { void initState() {
super.initState(); super.initState();
_snapshot = widget.repository.load(); _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) _fly = AnimationController(vsync: this, duration: GalaxyMotion.recenter)
..addListener(_onFly); ..addListener(_onFly);
_ticker = createTicker(_tick); _ticker = createTicker(_tick);
@@ -182,12 +183,18 @@ class _GalaxyScreenState extends State<GalaxyScreen>
void _follow() { void _follow() {
final p = _selected; final p = _selected;
if (p == null || _viewport.isEmpty || _fly.isAnimating) return; 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 s = _view.value.getMaxScaleOnAxis();
final tx = _viewport.width / 2 - at.dx * s; final tx = _viewport.width / 2 - at.dx * s;
final ty = _viewport.height / 2 - at.dy * s; final ty = _viewport.height / 2 - at.dy * s;
final m = _view.value; 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; return;
} }
_view.value = m.clone() _view.value = m.clone()
@@ -258,9 +265,14 @@ class _GalaxyScreenState extends State<GalaxyScreen>
return LiveStreamBuilder<EclipseState?>( return LiveStreamBuilder<EclipseState?>(
key: ValueKey('eclipse-${myFandomId ?? 'mine'}'), key: ValueKey('eclipse-${myFandomId ?? 'mine'}'),
create: () => widget.eclipse.watch(fandomId: myFandomId), create: () => widget.eclipse.watch(fandomId: myFandomId),
builder: (context, eclipse) => LiveStreamBuilder<PlanetVisualState>( builder: (context, eclipse) =>
key: ValueKey(('galaxy-orbit', (_selected ?? data.mine)?.id)), LiveStreamBuilder<PlanetVisualState>(
create: () => widget.watchPlanet((_selected ?? data.mine)?.id ?? ''), key: ValueKey((
'galaxy-orbit',
(_selected ?? data.mine)?.id,
)),
create: () =>
widget.watchPlanet((_selected ?? data.mine)?.id ?? ''),
builder: (context, state) { builder: (context, state) {
_clock.idleSpeedScale = state?.isHibernating == true _clock.idleSpeedScale = state?.isHibernating == true
? 0.25 ? 0.25
@@ -280,7 +292,9 @@ class _GalaxyScreenState extends State<GalaxyScreen>
view: _view, view: _view,
selected: _selected ?? data.mine, selected: _selected ?? data.mine,
selectedIndex: _selected == null selectedIndex: _selected == null
? data.planets.indexWhere((p) => p.isMine).clamp(0, 1 << 30) ? data.planets
.indexWhere((p) => p.isMine)
.clamp(0, 1 << 30)
: _selectedIndex, : _selectedIndex,
balance: _zap.balance, balance: _zap.balance,
pending: _zap.pending, pending: _zap.pending,
@@ -433,9 +447,14 @@ class _Map extends StatelessWidget {
child: AnimatedBuilder( child: AnimatedBuilder(
animation: clock, animation: clock,
builder: (context, _) => GalaxyField( builder: (context, _) => GalaxyField(
onOpenHistory: (planet) => Navigator.of(context).push(MaterialPageRoute<void>( onOpenHistory: (planet) => Navigator.of(context).push(
builder: (_) => PlanetHistoryScreen(repository: planetRepository(planet.id), fandomName: planet.name), MaterialPageRoute<void>(
)), builder: (_) => PlanetHistoryScreen(
repository: planetRepository(planet.id),
fandomName: planet.name,
),
),
),
planets: data.planets, planets: data.planets,
watchPlanet: watchPlanet, watchPlanet: watchPlanet,
selected: selected, selected: selected,
@@ -522,7 +541,19 @@ class _Map extends StatelessWidget {
tooltip: context.l10n.commonSearch, tooltip: context.l10n.commonSearch,
onPressed: () => Navigator.of(context, rootNavigator: true).push( onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(repository: repository), builder: (_) => GalaxySearchScreen(
repository: repository,
onOpen: (p) =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository(p.id),
watchPlanet: watchPlanet,
eclipse: eclipseRepository,
),
),
),
),
), ),
), ),
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
@@ -540,10 +571,14 @@ class _Map extends StatelessWidget {
if (eclipse case final e?) if (eclipse case final e?)
EclipseBannerCard( EclipseBannerCard(
state: e, state: e,
onTap: () => Navigator.of(context, rootNavigator: true).push( onTap: () =>
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen( Navigator.of(context, rootNavigator: true).push(
repository: eclipseRepository, fandomId: e.fandomId, MaterialPageRoute<void>(
)), builder: (_) => EclipseDefendScreen(
repository: eclipseRepository,
fandomId: e.fandomId,
),
),
), ),
), ),
if (selected case final target?) if (selected case final target?)
@@ -551,12 +586,15 @@ class _Map extends StatelessWidget {
fandomId: target.id, fandomId: target.id,
fandomName: target.name, fandomName: target.name,
watch: watchPlanet, watch: watchPlanet,
onOpen: () => Navigator.of(context, rootNavigator: true).push( onOpen: () =>
MaterialPageRoute<void>(builder: (_) => PlanetDetailScreen( Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository(target.id), repository: planetRepository(target.id),
watchPlanet: watchPlanet, watchPlanet: watchPlanet,
eclipse: eclipseRepository, eclipse: eclipseRepository,
)), ),
),
), ),
), ),
], ],
@@ -618,9 +656,12 @@ class _Map extends StatelessWidget {
key: ValueKey(('zap-mode', data.mine?.id)), key: ValueKey(('zap-mode', data.mine?.id)),
create: () => watchPlanet(data.mine?.id ?? ''), create: () => watchPlanet(data.mine?.id ?? ''),
builder: (context, state) { builder: (context, state) {
final hero = state?.isHeroTime == true && final hero =
state?.isHeroTime == true &&
state?.heroEndsAt?.isAfter(DateTime.now()) == true && state?.heroEndsAt?.isAfter(DateTime.now()) == true &&
pending == 0 && !sending && retryZap == null; pending == 0 &&
!sending &&
retryZap == null;
return ZapButton( return ZapButton(
isHeroTime: hero, isHeroTime: hero,
// 단가는 `config/policy.zapPop` 이다 — Figma 「Zap 버튼」이 // 단가는 `config/policy.zapPop` 이다 — Figma 「Zap 버튼」이
@@ -641,9 +682,11 @@ class _Map extends StatelessWidget {
if (data.mine case final mine?) { if (data.mine case final mine?) {
if (hero) { if (hero) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, isScrollControlled: true, context: context,
isScrollControlled: true,
builder: (_) => HeroParticipationSheet( builder: (_) => HeroParticipationSheet(
fandomId: mine.id, watch: watchPlanet, fandomId: mine.id,
watch: watchPlanet,
repository: planetRepository(mine.id), repository: planetRepository(mine.id),
), ),
); );
@@ -658,7 +701,8 @@ class _Map extends StatelessWidget {
onZap(mine.id); onZap(mine.id);
} }
}, },
onContribute: () => _todo(context, context.l10n.commonContribute), onContribute: () =>
_todo(context, context.l10n.commonContribute),
onItem: () => _todo(context, context.l10n.commonItem), onItem: () => _todo(context, context.l10n.commonItem),
); );
}, },
@@ -866,7 +910,10 @@ class _HudCard extends StatelessWidget {
), ),
), ),
// Stage0 성운은 랭킹 모집단이 아니다(B-17). // 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), const SizedBox(height: AppSpace.s1),
@@ -16,10 +16,14 @@ class GalaxySearchScreen extends StatefulWidget {
const GalaxySearchScreen({ const GalaxySearchScreen({
super.key, super.key,
this.repository = const SampleGalaxyRepository(), this.repository = const SampleGalaxyRepository(),
this.onOpen,
}); });
final GalaxyRepository repository; final GalaxyRepository repository;
/// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다.
final void Function(PlanetNode planet)? onOpen;
@override @override
State<GalaxySearchScreen> createState() => _GalaxySearchScreenState(); State<GalaxySearchScreen> createState() => _GalaxySearchScreenState();
} }
@@ -153,7 +157,12 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
itemCount: hits.length, itemCount: hits.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2), 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,14 +266,18 @@ class _NoHit extends StatelessWidget {
} }
class _Hit extends StatelessWidget { class _Hit extends StatelessWidget {
const _Hit({required this.planet}); const _Hit({required this.planet, this.onTap});
final PlanetNode planet; final PlanetNode planet;
final VoidCallback? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
return Container( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer, color: AppColors.cosmicSurfaceContainer,
@@ -292,6 +305,7 @@ class _Hit extends StatelessWidget {
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall), Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
], ],
), ),
),
); );
} }
} }
@@ -73,12 +73,20 @@ class GalaxyField extends StatelessWidget {
/// 90개가 사라진다. 바깥 궤도가 [edgeMargin] 안쪽에 서도록 간격을 나눈다. /// 90개가 사라진다. 바깥 궤도가 [edgeMargin] 안쪽에 서도록 간격을 나눈다.
static double ringOf(int rank, int outerRank) { static double ringOf(int rank, int outerRank) {
final rings = math.max(1, outerRank - 1); 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; return innerRing + (rank - 1) * gap;
} }
/// 행성의 논리 좌표. 그리기·히트 테스트·HUD 가 같은 식을 써야 어긋나지 않는다. /// 행성의 논리 좌표. 그리기·히트 테스트·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); if (p.isCenter) return const Offset(extent / 2, extent / 2);
// 순위가 낮을수록 멀어진다. 중심 행성을 뺀 나머지를 고리에 흩는다. // 순위가 낮을수록 멀어진다. 중심 행성을 뺀 나머지를 고리에 흩는다.
final ring = ringOf(p.rank, outerRank); final ring = ringOf(p.rank, outerRank);
@@ -101,7 +109,10 @@ class GalaxyField extends StatelessWidget {
/// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이. /// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이.
static double depthOf(Offset at) => 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 깊이 배율. 궤도 앞쪽일수록 크다. /// 2.5D 깊이 배율. 궤도 앞쪽일수록 크다.
static double depthScaleOf(Offset at) => static double depthScaleOf(Offset at) =>
@@ -125,7 +136,9 @@ class GalaxyField extends StatelessWidget {
/// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다. /// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다.
static String imageOf(PlanetNode p, int index) => p.isMine static String imageOf(PlanetNode p, int index) => p.isMine
? 'assets/figma/planet-home.png' ? '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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -147,7 +160,10 @@ class GalaxyField extends StatelessWidget {
Positioned.fill( Positioned.fill(
child: RepaintBoundary( child: RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: _BackdropPainter(planets: planets, outerRank: outerRank), painter: _BackdropPainter(
planets: planets,
outerRank: outerRank,
),
isComplex: true, isComplex: true,
willChange: false, willChange: false,
), ),
@@ -169,7 +185,9 @@ class GalaxyField extends StatelessWidget {
opacity: depthOpacityOf(at), opacity: depthOpacityOf(at),
selected: selected?.id == p.id, selected: selected?.id == p.id,
onTap: () => onSelect(p), 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. 본체 일러스트(표면 장식·구름 포함). // 3~5. 본체 일러스트(표면 장식·구름 포함).
LivePlanet25D(onOpenHistory: onOpenHistory, fandomId: planet.id, watch: watchPlanet, image: image, size: side, stage: planet.stage, glow: glow, LivePlanet25D(
animate: selected, lowEffects: !selected), onOpenHistory: onOpenHistory,
fandomId: planet.id,
watch: watchPlanet,
image: image,
size: side,
stage: planet.stage,
glow: glow,
animate: selected,
lowEffects: !selected,
),
Positioned( Positioned(
bottom: -4, bottom: -4,
child: Text( child: Text(
@@ -257,7 +284,9 @@ class _PlanetHit extends StatelessWidget {
fontWeight: planet.isMine fontWeight: planet.isMine
? FontWeight.w600 ? FontWeight.w600
: FontWeight.w400, : 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<PlanetNode> a, List<PlanetNode> b) { static bool _sameRanks(List<PlanetNode> a, List<PlanetNode> b) {
for (var i = 0; i < a.length; i++) { 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; return true;
} }
@@ -148,7 +148,8 @@ class _GalaxyWebViewState extends State<GalaxyWebView>
} }
bool get _visible { 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; if (ModalRoute.of(context)?.isCurrent == false) return false;
final box = context.findRenderObject(); final box = context.findRenderObject();
if (box is! RenderBox || !box.attached || !box.hasSize) return true; if (box is! RenderBox || !box.attached || !box.hasSize) return true;
@@ -80,7 +80,8 @@ class _ZapButtonState extends State<ZapButton> {
} }
bool get _canZap => bool get _canZap =>
widget.blockedReason == null && (widget.isHeroTime || widget.myPop >= widget.zapPop); widget.blockedReason == null &&
(widget.isHeroTime || widget.myPop >= widget.zapPop);
@override @override
Widget build(BuildContext context) => AnimatedSize( Widget build(BuildContext context) => AnimatedSize(
@@ -180,7 +181,9 @@ class _ZapButtonState extends State<ZapButton> {
icon: const Icon(Icons.bolt), icon: const Icon(Icons.bolt),
label: Text( label: Text(
_canZap _canZap
? widget.isHeroTime ? 'ZAP+ · HERO TIME' : 'ZAP ${widget.zapPop} POP' ? widget.isHeroTime
? 'ZAP+ · HERO TIME'
: 'ZAP ${widget.zapPop} POP'
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop), : (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
), ),
), ),
+18
View File
@@ -447,6 +447,24 @@ class _HomeBody extends StatelessWidget {
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen( builder: (_) => GalaxySearchScreen(
repository: zap!.repository, repository: zap!.repository,
onOpen: (p) =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) =>
PlanetDetailScreen(
repository:
planetRepository!(
p.id,
),
watchPlanet:
watchPlanet,
eclipse: eclipse,
),
),
),
), ),
), ),
), ),
+9
View File
@@ -471,6 +471,15 @@ void main() {
expect(find.text('전체 삭제'), findsOneWidget); 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 { testWidgets('없는 이름이면 사유를 알린다', (tester) async {
await _pump(tester, const GalaxySearchScreen()); await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'zzzz'); await tester.enterText(find.byType(TextField), 'zzzz');