갤럭시 검색 결과에서 행성 상세로 이동
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,10 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
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<GalaxyHomeScreen> {
|
||||
final myPop = (await _snapshot).myPop;
|
||||
final fandomId = await navigator.push(
|
||||
MaterialPageRoute<String>(
|
||||
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<void>(
|
||||
builder: (_) => PlanetDetailScreen(
|
||||
@@ -182,6 +188,22 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxySearchScreen(
|
||||
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,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: Center(
|
||||
child: _NebulaPill(onTap: _createNebula),
|
||||
),
|
||||
child: Center(child: _NebulaPill(onTap: _createNebula)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
// 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다.
|
||||
|
||||
@@ -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<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 planets = <PlanetNode>[
|
||||
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<String, dynamic> data, int rank, String myFandomId) => PlanetNode(
|
||||
PlanetNode _node(
|
||||
String id,
|
||||
Map<String, dynamic> data,
|
||||
int rank,
|
||||
String myFandomId,
|
||||
) => PlanetNode(
|
||||
id: id,
|
||||
stage: switch (data['stage']) {
|
||||
int n when n >= 0 && n <= 6 => n,
|
||||
@@ -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 <String, dynamic>{}).intOr('zapPop', 10);
|
||||
final zapPop = (snapshot.data() ?? const <String, dynamic>{}).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<int>(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<int>(0, (sum, lot) => sum - (lot['delta'] as int))
|
||||
: null;
|
||||
return ZapResult(
|
||||
contributedPop: contributedPop,
|
||||
txId: data['txId'] as String? ?? '',
|
||||
|
||||
@@ -125,7 +125,8 @@ class _GalaxyScreenState extends State<GalaxyScreen>
|
||||
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<GalaxyScreen>
|
||||
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<GalaxyScreen>
|
||||
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();
|
||||
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,
|
||||
);
|
||||
},
|
||||
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<void>(
|
||||
builder: (_) => PlanetHistoryScreen(repository: planetRepository(planet.id), fandomName: planet.name),
|
||||
)),
|
||||
onOpenHistory: (planet) => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<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),
|
||||
@@ -540,24 +571,31 @@ class _Map extends StatelessWidget {
|
||||
if (eclipse case final e?)
|
||||
EclipseBannerCard(
|
||||
state: e,
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
|
||||
repository: eclipseRepository, fandomId: e.fandomId,
|
||||
)),
|
||||
),
|
||||
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,
|
||||
)),
|
||||
),
|
||||
onOpen: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<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),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -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),
|
||||
|
||||
@@ -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<GalaxySearchScreen> createState() => _GalaxySearchScreenState();
|
||||
}
|
||||
@@ -153,7 +157,12 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<PlanetNode> a, List<PlanetNode> b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter) return false;
|
||||
if (a[i].rank != b[i].rank || a[i].isCenter != b[i].isCenter)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ class _GalaxyWebViewState extends State<GalaxyWebView>
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -80,7 +80,8 @@ class _ZapButtonState extends State<ZapButton> {
|
||||
}
|
||||
|
||||
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<ZapButton> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user