HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/// 갤럭시 맵이 그리는 값의 형태.
|
||||
///
|
||||
/// 화면과 데이터를 갈라 둔 이유는 홈과 같다 — 5주차 Firestore 연동 때
|
||||
/// 구현체만 갈아 끼운다.
|
||||
///
|
||||
/// 근거는 Figma `03_Wireframe` 「갤럭시맵 화면」 명세다.
|
||||
/// *데이터 : 3d 갤럭시 — FPI 상세 지수 변동 데이터 · HUD — Rank, FPI 총점, 접속자수(멤버수)*
|
||||
library;
|
||||
|
||||
/// 맵 위의 행성 하나.
|
||||
class PlanetNode {
|
||||
const PlanetNode({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.rank,
|
||||
required this.fpi,
|
||||
required this.members,
|
||||
required this.online,
|
||||
this.isMine = false,
|
||||
this.stage,
|
||||
this.colorKey,
|
||||
bool? isCenter,
|
||||
}) : isCenter = isCenter ?? isMine;
|
||||
|
||||
/// Firestore `fandoms/{id}` 문서 ID. HUD 에서 행성 상세로 넘어갈 때
|
||||
/// **이 값으로 저장소를 만든다** — 없으면 어느 행성을 눌러도 같은 상세가 뜬다.
|
||||
final String id;
|
||||
|
||||
/// 팬덤 서버 Stage 0~6; 미제공이면 null.
|
||||
final int? stage;
|
||||
|
||||
/// 팬덤 `colorKey`(행성 색 팔레트 `planetColors` 의 키). 미제공이면 null.
|
||||
final String? colorKey;
|
||||
|
||||
final String name;
|
||||
|
||||
/// 갤럭시 전체 순위. HUD 의 `Rank`.
|
||||
///
|
||||
/// Stage0 성운은 랭킹 모집단이 아니다(원장 B-08 · B-17). 성운에는 순위를 매기지 않고
|
||||
/// 정식 행성 수 + 1 을 넣어 **가장 바깥 궤도 자리로만** 쓴다 — HUD 는 「-」로 그린다.
|
||||
final int rank;
|
||||
|
||||
/// FPI 총점. 산출식은 정책 설계서 §P-03 — 초기 MVP 는 단순 합산이다.
|
||||
final int fpi;
|
||||
|
||||
final int members;
|
||||
|
||||
/// 접속자 수. 명세의 「접속자수(맴버수)」.
|
||||
final int online;
|
||||
|
||||
/// 내 행성. 명세 규칙 「행성 — 나의행성 한가운데」.
|
||||
final bool isMine;
|
||||
|
||||
/// 맵 한가운데 서는 행성. 기본은 내 행성이고, Ranking View 에서는 FPI 1위다(원장 B-21).
|
||||
final bool isCenter;
|
||||
}
|
||||
|
||||
/// 하단 이벤트 띠에 흐르는 한 줄.
|
||||
///
|
||||
/// 명세 — *이벤트1 아이템 사용(닉네임·아이템명) · 이벤트2 이클립스(행성/성운명)*.
|
||||
/// 디폴트는 숨김이고 이벤트가 나면 우→좌로 1회 흐른다.
|
||||
enum GalaxyEventKind { itemUsed, eclipse }
|
||||
|
||||
class GalaxyEvent {
|
||||
const GalaxyEvent({required this.kind, required this.message});
|
||||
final GalaxyEventKind kind;
|
||||
final String message;
|
||||
}
|
||||
|
||||
class GalaxySnapshot {
|
||||
const GalaxySnapshot({
|
||||
required this.planets,
|
||||
required this.myPop,
|
||||
this.zapPop = 10,
|
||||
this.event,
|
||||
this.joined = true,
|
||||
this.memberships = const [],
|
||||
});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
|
||||
/// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」).
|
||||
/// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다.
|
||||
final List<PlanetNode> memberships;
|
||||
|
||||
/// 내 활동 POP 잔액. Zap 이 여기서 차감된다.
|
||||
final int myPop;
|
||||
|
||||
/// ZAP 1 회 기여 POP. `config/policy.zapPop` — Figma 「Zap 버튼」이
|
||||
/// 「어드민에서 설정한 기여 POP」이라 못 박은 값이다.
|
||||
final int zapPop;
|
||||
|
||||
final GalaxyEvent? event;
|
||||
|
||||
/// 행성·성운에 가입했거나 만든 사용자인가.
|
||||
/// 명세 규칙 — *행성/성운 가입 또는 생성한 유저에게만 노출* (Zap 버튼).
|
||||
final bool joined;
|
||||
|
||||
PlanetNode? get mine =>
|
||||
planets.where((p) => p.isMine).firstOrNull;
|
||||
|
||||
/// Ranking View — 원장 B-21 「Ranking View 는 FPI 상위 중심」. FPI 1위 행성을 가운데
|
||||
/// 두고 나머지는 기본 보기와 같이 순위 궤도를 돈다. Stage0 성운은 랭킹 모집단이 아니므로
|
||||
/// (B-17) 중심 후보에서 뺀다. 공동 1위면 목록 순서(C-19 고정 정렬키)의 첫 행성이다.
|
||||
GalaxySnapshot get rankingView {
|
||||
PlanetNode? top;
|
||||
for (final p in planets) {
|
||||
if (p.stage != 0 && (top == null || p.rank < top.rank)) top = p;
|
||||
}
|
||||
return GalaxySnapshot(
|
||||
planets: [
|
||||
for (final p in planets)
|
||||
PlanetNode(
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
rank: p.rank,
|
||||
fpi: p.fpi,
|
||||
members: p.members,
|
||||
online: p.online,
|
||||
isMine: p.isMine,
|
||||
stage: p.stage,
|
||||
isCenter: identical(p, top),
|
||||
),
|
||||
],
|
||||
myPop: myPop,
|
||||
zapPop: zapPop,
|
||||
event: event,
|
||||
joined: joined,
|
||||
memberships: memberships,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ZAP 한 번(또는 연타 묶음)의 결과. 서버가 확정한 잔액만 담는다.
|
||||
class ZapResult {
|
||||
const ZapResult({required this.txId, required this.balanceAfter, this.contributedPop});
|
||||
|
||||
final String txId;
|
||||
|
||||
/// 서버 차감 lot 합계. 누락되면 요청액으로 추정하지 않는다.
|
||||
final int? contributedPop;
|
||||
|
||||
/// 차감 후 잔액. **-1 은 「서버가 알려 주지 않았다」** 로, 화면은 직전 값을 지킨다.
|
||||
final int balanceAfter;
|
||||
}
|
||||
|
||||
abstract interface class GalaxyRepository {
|
||||
Future<GalaxySnapshot> load();
|
||||
|
||||
/// ZAP — **행성 성장 기여**다. 이클립스 방어가 아니다.
|
||||
///
|
||||
/// 별첨 B **B-23** — *ZAP 차감액은 누적 기여 POP 에 실제 값으로 반영되고, 행성
|
||||
/// 성장용 Growth 진행도에만 가중을 둔다(원장·sFPI 에는 가중 미적용)*. Figma
|
||||
/// 「Zap 버튼」의 *「어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가」*
|
||||
/// 가 이 경로다.
|
||||
///
|
||||
/// 차감은 `fn-pop-spend` 가 한다 — 화면이 POP 을 직접 깎지 않는다(별첨 1 ②).
|
||||
/// [count] 는 연타로 묶은 횟수이며 **원장 행에 그대로 남는다** — MY 의 POP 내역이
|
||||
/// 「Zap N 회」를 행 수로 세면 실제보다 작게 나온다.
|
||||
Future<ZapResult> zap({
|
||||
required String fandomId,
|
||||
required int count,
|
||||
required String clientSeq,
|
||||
});
|
||||
|
||||
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
|
||||
/// E-FDM-04 로 거부된다.
|
||||
Future<void> setPrimary(String fandomId);
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
class SampleGalaxyRepository implements GalaxyRepository {
|
||||
const SampleGalaxyRepository({this.joined = true});
|
||||
|
||||
final bool joined;
|
||||
|
||||
@override
|
||||
Future<void> setPrimary(String fandomId) async {}
|
||||
|
||||
/// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다.
|
||||
@override
|
||||
Future<ZapResult> zap({
|
||||
required String fandomId,
|
||||
required int count,
|
||||
required String clientSeq,
|
||||
}) async => const ZapResult(txId: '', balanceAfter: -1);
|
||||
|
||||
@override
|
||||
Future<GalaxySnapshot> load() async => GalaxySnapshot(
|
||||
myPop: 12540,
|
||||
joined: joined,
|
||||
event: const GalaxyEvent(
|
||||
kind: GalaxyEventKind.itemUsed,
|
||||
message: '아미보이님이 메가 드랍을 사용했습니다',
|
||||
),
|
||||
planets: 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: '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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/data/planet_visual_state.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../notification/notification_screen.dart';
|
||||
import '../eclipse/eclipse_data.dart';
|
||||
import '../eclipse/widgets/eclipse_banner.dart';
|
||||
import 'galaxy_data.dart';
|
||||
import '../planet/planet_data.dart';
|
||||
import 'galaxy_screen.dart';
|
||||
import 'galaxy_search_screen.dart';
|
||||
import '../home/home_data.dart';
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../home/nebula_create_screen.dart';
|
||||
import '../planet/planet_detail_screen.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// GALAXY 탭 루트 — 시안 `354:220228` Galaxy.
|
||||
///
|
||||
/// 시안은 「내 행성」 카드 그리드다. 카드 109×108 `#2c2c2c` r12, 간격 8, 세 칸.
|
||||
/// 선택된 행성은 `#ff7ddd` 2px 테두리와 「선택」 핑크 알약이 붙는다. 하단 가운데에
|
||||
/// 「성운 생성」 알약(106×36 · `#19191c` · `#232324` 1px)이 뜬다.
|
||||
///
|
||||
/// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라
|
||||
/// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다.
|
||||
class GalaxyHomeScreen extends StatefulWidget {
|
||||
const GalaxyHomeScreen({
|
||||
super.key,
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
required this.repository,
|
||||
required this.planetRepository,
|
||||
required this.eclipse,
|
||||
this.notifications = const SampleNotificationRepository(),
|
||||
this.home = const SampleHomeRepository(),
|
||||
});
|
||||
|
||||
final GalaxyRepository repository;
|
||||
|
||||
/// 「성운 생성」 경로(`fn-nebula-create`).
|
||||
final HomeRepository home;
|
||||
final PlanetWatch watchPlanet;
|
||||
|
||||
/// 상단 알림 입구가 여는 알림함(F0)의 저장소.
|
||||
final NotificationRepository notifications;
|
||||
|
||||
/// 궤도 맵을 지나 행성 상세까지 그대로 내려간다.
|
||||
final PlanetRepositoryFactory planetRepository;
|
||||
|
||||
/// 이클립스 경보·방어. 궤도 맵에도 그대로 넘긴다.
|
||||
final EclipseRepository eclipse;
|
||||
|
||||
@override
|
||||
State<GalaxyHomeScreen> createState() => _GalaxyHomeScreenState();
|
||||
}
|
||||
|
||||
class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot = widget.repository.load();
|
||||
|
||||
/// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로.
|
||||
Future<void> _choose(PlanetNode p) async {
|
||||
if (p.isMine) return _openMap();
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.galaxySetPrimaryConfirm,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
try {
|
||||
await widget.repository.setPrimary(p.id);
|
||||
if (mounted) setState(() { _snapshot = widget.repository.load(); });
|
||||
} on BackendException catch (e) {
|
||||
// 30일 대기(E-FDM-04) 등 서버 사유를 그대로 알린다.
|
||||
if (mounted) await showAlertDialog(context, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
void _openMap() => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxyScreen(
|
||||
repository: widget.repository,
|
||||
watchPlanet: widget.watchPlanet,
|
||||
planetRepository: widget.planetRepository,
|
||||
eclipse: widget.eclipse,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다.
|
||||
Future<void> _createNebula() async {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final myPop = (await _snapshot).myPop;
|
||||
final fandomId = await navigator.push(
|
||||
MaterialPageRoute<String>(
|
||||
builder: (_) => NebulaCreateScreen(repository: widget.home, myPop: myPop),
|
||||
),
|
||||
);
|
||||
if (fandomId == null || !mounted) return;
|
||||
setState(() { _snapshot = widget.repository.load(); });
|
||||
await navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlanetDetailScreen(
|
||||
nebula: true,
|
||||
repository: widget.planetRepository(fandomId),
|
||||
watchPlanet: widget.watchPlanet,
|
||||
eclipse: widget.eclipse,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: FutureBuilder<GalaxySnapshot>(
|
||||
future: _snapshot,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final data = snap.data;
|
||||
if (snap.hasError || data == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.galaxyLoadFailed, style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
setState(() => _snapshot = widget.repository.load()),
|
||||
child: Text(context.l10n.commonRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// 시안은 내 행성 셋을 보여 준다 — 가입하거나 만든 행성/성운, 최신 가입순(§2.58).
|
||||
// 「선택」 알약은 대표 팬덤(C-15)이다.
|
||||
final mine = data.memberships.take(3).toList();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s9),
|
||||
children: [
|
||||
// 상단 — 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'GALAXY',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 20,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_HeaderIcon(
|
||||
asset: 'assets/figma/icon-search-42.png',
|
||||
label: context.l10n.commonSearch,
|
||||
onTap: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxySearchScreen(
|
||||
repository: widget.repository,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_HeaderIcon(
|
||||
asset: 'assets/figma/icon-bell-42.png',
|
||||
label: context.l10n.homeNotificationsTooltip,
|
||||
onTap: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => NotificationScreen(
|
||||
repository: widget.notifications,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱
|
||||
// 워닝 배너 병행」. 대상은 내 행성이다.
|
||||
if (mine.where((p) => p.isMine).firstOrNull case final my?)
|
||||
EclipseBanner(
|
||||
repository: widget.eclipse,
|
||||
fandomId: my.id,
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
AppSpace.s3,
|
||||
16,
|
||||
0,
|
||||
),
|
||||
onTap: _openMap,
|
||||
),
|
||||
// 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.galaxyMyPlanets,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 26,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
InkResponse(
|
||||
onTap: _openMap,
|
||||
radius: 20,
|
||||
child: Image.asset(
|
||||
'assets/figma/icon-list.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: const Color(0xFF777777),
|
||||
semanticLabel: context.l10n.galaxyMap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (i, p) in mine.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: AppSpace.s2),
|
||||
Expanded(
|
||||
child: _PlanetCard(
|
||||
planet: p,
|
||||
image: _imageFor(i),
|
||||
selected: p.isMine,
|
||||
onTap: () => _choose(p),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다.
|
||||
for (var i = mine.length; i < 3; i++) ...[
|
||||
if (i > 0) const SizedBox(width: AppSpace.s2),
|
||||
const Expanded(child: _EmptySlot()),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 「성운 생성」 — 시안 하단 가운데 알약
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: Center(
|
||||
child: _NebulaPill(onTap: _createNebula),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 카드 그림. 시안 세 칸(`image 8` · `image 15` · 파란 행성 벡터)을 순서대로 쓴다.
|
||||
static String _imageFor(int i) => switch (i) {
|
||||
0 => 'assets/figma/planet-detail-hero.png',
|
||||
1 => 'assets/figma/nebula-card.png',
|
||||
_ => 'assets/figma/planet-earth.png',
|
||||
};
|
||||
}
|
||||
|
||||
/// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와
|
||||
/// 오른쪽 위에 붙은 「선택」 알약(32×24 · 12/600).
|
||||
class _PlanetCard extends StatelessWidget {
|
||||
const _PlanetCard({
|
||||
required this.planet,
|
||||
required this.image,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PlanetNode planet;
|
||||
final String image;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Material(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: selected
|
||||
? Border.all(color: AppColors.cosmicAccent, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(11, 10, 10, 11),
|
||||
child: Image.asset(
|
||||
image,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: planet.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.commonSelect,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 상단 42 아이콘(시안 `icon` 42×42 — 24 그림 + 여백).
|
||||
class _HeaderIcon extends StatelessWidget {
|
||||
const _HeaderIcon({
|
||||
required this.asset,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String asset;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 21,
|
||||
child: Image.asset(asset, width: 42, height: 42, semanticLabel: label),
|
||||
);
|
||||
}
|
||||
|
||||
class _EmptySlot extends StatelessWidget {
|
||||
const _EmptySlot();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AspectRatio(
|
||||
aspectRatio: 109 / 108,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _NebulaPill extends StatelessWidget {
|
||||
const _NebulaPill({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
// 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ImageIcon(
|
||||
AppIcons.badge,
|
||||
size: 16,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.nebulaCreate,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 20 / 14,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show Offset;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 갤럭시 공간 인터랙션의 조정 손잡이 — **여기 말고 다른 데에 수치를 흩지 않는다.**
|
||||
///
|
||||
/// 발주사 검토회신서 3절이 요구한 것은 기능이 아니라 체감이다
|
||||
/// (*드래그·회전 움직임과 관성 · 행성 간 이동감 · 깊이감*). 체감은 숫자 몇 개를
|
||||
/// 돌려 가며 맞추는 일이라, 값이 위젯 안에 박혀 있으면 조정할 수가 없다.
|
||||
///
|
||||
/// 기준값의 출처는 발주사 프로토타입이다 —
|
||||
/// `FANUZU_HUD Proto.html` 의 `lerp(cur, target, 0.03~0.08)` 감쇠,
|
||||
/// 행성에 붙은 HUD 스프라이트(`hud.position.y = radius + 5`),
|
||||
/// `time * 0.8/(rank+1)` 궤도 회전([idleSpin]).
|
||||
abstract final class GalaxyMotion {
|
||||
// ── 회전 관성 ─────────────────────────────────────────────────────────
|
||||
/// 손을 대지 않았을 때 [GalaxyClock.spin] 이 흐르는 속도(rad/s).
|
||||
///
|
||||
/// **행성의 실제 각속도는 이 값이 아니다** — [GalaxyField.positionOf] 가
|
||||
/// `spin * orbitSpeedOf(rank)` 를 각도로 쓰므로
|
||||
/// `idleSpin × 1.6/(rank+1)` = `0.4/(rank+1)` rad/s 다.
|
||||
/// 프로토타입 `FANUZU_HUD Proto.html` 이 `time = Date.now()*0.0005`(0.5rad/s)에
|
||||
/// `speed = 0.8/(rank+1)` 을 곱해 `0.4/(rank+1)` 을 쓴다 — 같은 값이다.
|
||||
/// (rank 2 → 0.133rad/s, 1회전 47초.)
|
||||
static const double idleSpin = 0.25;
|
||||
|
||||
/// 가로 드래그 속도(px/s) → 각속도(rad/s) 환산 계수.
|
||||
static const double dragSpinGain = 0.0011;
|
||||
|
||||
/// 궤도를 돌리는 드래그 성분만 골라낸다. 나머지는 0 이다.
|
||||
///
|
||||
/// [InteractiveViewer] 는 팬·핀치·회전을 한 콜백으로 준다. `focalPointDelta.dx`
|
||||
/// 를 그대로 넘기면 **세로 드래그와 핀치 줌이 각속도를 덮어써** 궤도가 선다
|
||||
/// ([GalaxyClock.advance] 는 드래그가 있는 프레임에 감쇠를 건너뛴다).
|
||||
static double spinDragOf(int pointerCount, Offset delta) =>
|
||||
pointerCount == 1 && delta.dx.abs() > delta.dy.abs() ? delta.dx : 0;
|
||||
|
||||
/// 각속도가 기본 표류로 돌아가는 감쇠율(1/s). 클수록 빨리 선다.
|
||||
/// e-폴딩 시간 = 1/이 값 ≈ 0.56초.
|
||||
static const double spinFriction = 1.8;
|
||||
|
||||
/// 각속도 상한(rad/s). 없으면 세게 던졌을 때 궤도가 눈으로 못 따라간다.
|
||||
static const double maxSpin = 2.0;
|
||||
|
||||
/// 링 맥동 한 주기(초).
|
||||
static const double pulsePeriod = 2.4;
|
||||
|
||||
// ── 줌 ────────────────────────────────────────────────────────────────
|
||||
static const double minZoom = 0.55;
|
||||
static const double maxZoom = 3.2;
|
||||
|
||||
/// 행성을 고르면 이 배율로 다가간다.
|
||||
static const double focusZoom = 1.5;
|
||||
|
||||
// ── 중심 전환 ─────────────────────────────────────────────────────────
|
||||
/// 「행성 터치시 화면 중심 전환이동」에 드는 시간.
|
||||
static const Duration recenter = Duration(milliseconds: 620);
|
||||
|
||||
// ── HUD ───────────────────────────────────────────────────────────────
|
||||
/// HUD 카드 폭(논리 px).
|
||||
static const double hudWidth = 214;
|
||||
|
||||
/// 행성 표면과 HUD 카드 사이 거리 — 둘을 잇는 선의 길이이기도 하다.
|
||||
/// 프로토타입 `hud.position.y = radius + 5` 의 `proximityOffset` 자리다.
|
||||
static const double hudGap = 18;
|
||||
|
||||
/// 위/아래를 정할 때 쓰는 HUD 높이 어림값. 실제 높이는 내용이 정한다.
|
||||
/// Figma 명세가 *「HUD - 행성 상단/하단」* 이라 둘 다 정상 배치다.
|
||||
static const double hudHeightHint = 96;
|
||||
|
||||
/// HUD 가 화면 가장자리에 붙지 않도록 남기는 여백.
|
||||
static const double hudMargin = 12;
|
||||
|
||||
// ── 깊이감 ────────────────────────────────────────────────────────────
|
||||
/// 궤도 타원의 세로 눌림. 1 이면 정원(위에서 수직으로 본 것), 작을수록 눕는다.
|
||||
static const double tilt = 0.62;
|
||||
|
||||
/// 앞뒤 크기 차. 궤도 앞쪽(화면 아래)이 `1+이 값`, 뒤쪽이 `1-이 값` 배.
|
||||
static const double depthScale = 0.26;
|
||||
|
||||
/// 뒤쪽 행성이 옅어지는 정도. 0 이면 앞뒤가 같은 진하기다.
|
||||
static const double depthFade = 0.34;
|
||||
|
||||
/// 선택 링의 반지름 배율. **1.6 보다 커야 한다** — 행성 일러스트가 반지름의
|
||||
/// 1.6배까지 그려지므로 그 안쪽에 그리면 그림에 가려 보이지 않는다.
|
||||
static const double selectionRing = 1.95;
|
||||
|
||||
/// 별밭이 맵을 따라오는 비율. 0 이면 완전히 고정, 1 이면 맵과 한 몸이다.
|
||||
static const double parallax = 0.12;
|
||||
|
||||
/// 별밭이 따라오는 최대 거리(px). 넘어가면 별밭이 화면 밖으로 빠진다.
|
||||
static const double parallaxLimit = 160;
|
||||
|
||||
/// 줌이 별밭에 먹히는 비율. 행성보다 덜 커져야 멀리 있어 보인다.
|
||||
static const double parallaxZoom = 0.25;
|
||||
|
||||
/// 궤도 각속도 배율 — 바깥 궤도일수록 느리다.
|
||||
/// 프로토타입 `const speed = 0.8 / (data.rank + 1)` 와 같은 꼴이다.
|
||||
static double orbitSpeedOf(int rank) => 1.6 / (rank + 1);
|
||||
}
|
||||
|
||||
/// 갤럭시의 시간 — 궤도 각도와 맥동을 한 곳에서 굴린다.
|
||||
///
|
||||
/// [AnimationController.repeat] 를 쓰지 않는 이유는 **각속도가 상수가 아니기
|
||||
/// 때문**이다. 드래그가 각속도를 밀어 올리고([drag]), 손을 떼면 마찰이 기본
|
||||
/// 표류로 되돌린다([advance]) — 이것이 검토회신서의 *「자연스러운 관성과 감속」*이다.
|
||||
class GalaxyClock extends ChangeNotifier {
|
||||
/// 궤도 회전 각도(rad). 단조 증가가 아니다 — 반대로 던지면 줄어든다.
|
||||
double spin = 0;
|
||||
|
||||
/// 0~1 을 반복하는 맥동 위상.
|
||||
double pulse = 0;
|
||||
|
||||
double _velocity = GalaxyMotion.idleSpin;
|
||||
|
||||
/// 선택 행성의 서버 상태가 정하는 주변 궤도 표류. 드래그는 그대로 유지한다.
|
||||
double idleSpeedScale = 1;
|
||||
|
||||
/// 이번 프레임에 쌓인 가로 드래그 거리(px).
|
||||
double _dragged = 0;
|
||||
|
||||
@visibleForTesting
|
||||
double get velocity => _velocity;
|
||||
|
||||
/// 드래그가 각속도를 민다. 프레임당 여러 번 들어올 수 있으므로 모아 둔다.
|
||||
void drag(double dx) => _dragged += dx;
|
||||
|
||||
/// [dt] 초만큼 시간을 흘린다.
|
||||
void advance(double dt) {
|
||||
if (dt <= 0) return;
|
||||
if (_dragged != 0) {
|
||||
// 손이 닿아 있는 동안의 각속도는 **드래그 거리가 아니라 드래그 속도**를
|
||||
// 따른다. 거리로 누적하면 천천히 오래 끌기만 해도 팽이처럼 돌아 버린다.
|
||||
_velocity = (_dragged / dt * GalaxyMotion.dragSpinGain)
|
||||
.clamp(-GalaxyMotion.maxSpin, GalaxyMotion.maxSpin);
|
||||
_dragged = 0;
|
||||
} else {
|
||||
// 지수 감쇠 — 프레임 간격이 흔들려도 같은 시간에 같은 만큼 준다.
|
||||
final decay = math.exp(-GalaxyMotion.spinFriction * dt);
|
||||
final idle = GalaxyMotion.idleSpin * idleSpeedScale;
|
||||
_velocity = idle + (_velocity - idle) * decay;
|
||||
}
|
||||
spin += _velocity * dt;
|
||||
pulse = (pulse + dt / GalaxyMotion.pulsePeriod) % 1.0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart' show QueryDocumentSnapshot;
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../planet/planet_data.dart' show rankWithTies;
|
||||
import 'galaxy_data.dart';
|
||||
|
||||
/// 갤럭시 맵의 실 서버 구현 — 화면 대조 원장 F2.
|
||||
///
|
||||
/// 순위는 앱에서 정렬하지 않고 서버의 `fpiScore` 내림차순을 그대로 받는다.
|
||||
/// 클라이언트가 순위를 다시 매기면 화면마다 값이 갈린다.
|
||||
class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
FirestoreGalaxyRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
/// 한 화면에 띄우는 행성 수. 더 늘리면 궤도가 겹쳐 읽히지 않는다.
|
||||
static const int visibleCount = 12;
|
||||
|
||||
@override
|
||||
Future<GalaxySnapshot> load() async {
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
|
||||
final userSnap = await db.doc('users/$uid').get();
|
||||
final user = userSnap.data() ?? const <String, dynamic>{};
|
||||
// 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다.
|
||||
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
// 「가입 또는 생성한 행성/성운」 — 최신 가입순. 시안 그리드가 세 칸이라 셋만 읽는다.
|
||||
// ponytail: 모아보기 리스트 그리드(명세 §2.58-2)를 붙이면 limit 과 페이지를 연다.
|
||||
final membershipsF = db
|
||||
.collection('users/$uid/memberships')
|
||||
.orderBy('joinedAt', descending: true)
|
||||
.limit(3)
|
||||
.get();
|
||||
|
||||
final fandomsF = db
|
||||
.collection('fandoms')
|
||||
.orderBy('fpiScore', descending: true)
|
||||
.limit(visibleCount)
|
||||
.get();
|
||||
final walletF = db.doc('users/$uid/wallet/main').get();
|
||||
final eventF = db
|
||||
.collection('galaxyEvents')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
// ZAP 단가는 운영값이다(Figma 「Zap 버튼」 = 어드민 설정). 로그인 전에도 읽을 수
|
||||
// 있게 `firestore.rules` 가 `config/{id}` 를 열어 두었다.
|
||||
final policyF = db.doc('config/policy').get();
|
||||
|
||||
final fandoms = (await fandomsF).docs;
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
final events = (await eventF).docs;
|
||||
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
||||
|
||||
// 순위는 1·1·3 공동 순위(C-19). 실값 `fpiScore` 동률만 같은 순위이고 동점 내부는
|
||||
// fandomId 오름차순으로 고정한다 — 쿼리 순서(동점은 ID 내림차순)를 그대로 쓰지 않는다.
|
||||
// Stage0 성운은 순위를 매기기 **전에** 뺀다(B-08 · B-17) — 성운이 끼면 정식 행성의
|
||||
// 순위 번호가 밀린다. 성운은 맨 바깥 궤도 자리(정식 수 + 1)에 둔다.
|
||||
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(
|
||||
formal,
|
||||
(d) => d.data()['fpiScore'] is num ? d.data()['fpiScore'] as num : 0,
|
||||
(d) => d.id,
|
||||
))
|
||||
_node(doc.id, doc.data(), rank, myFandomId),
|
||||
for (final doc in fandoms.where(isNebula))
|
||||
_node(doc.id, doc.data(), formal.length + 1, myFandomId),
|
||||
];
|
||||
final byId = {for (final p in planets) p.id: p};
|
||||
final membershipIds = [for (final d in (await membershipsF).docs) d.id];
|
||||
// 맵(FPI 상위 12)에 없는 가입 팬덤만 따로 읽는다.
|
||||
final missing = await Future.wait([
|
||||
for (final id in membershipIds.where((id) => !byId.containsKey(id)))
|
||||
db.doc('fandoms/$id').get(),
|
||||
]);
|
||||
for (final doc in missing) {
|
||||
final data = doc.data();
|
||||
if (data != null) byId[doc.id] = _node(doc.id, data, formal.length + 1, myFandomId);
|
||||
}
|
||||
|
||||
return GalaxySnapshot(
|
||||
planets: planets,
|
||||
myPop: wallet.intOr('activityBalance'),
|
||||
zapPop: policy.intOr('zapPop', 10),
|
||||
event: events.isEmpty ? null : _event(events.first.data()),
|
||||
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
|
||||
joined: myFandomId.isNotEmpty,
|
||||
memberships: [for (final id in membershipIds) ?byId[id]],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
_ => null,
|
||||
},
|
||||
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
|
||||
name: data.strOr('name', id),
|
||||
rank: rank,
|
||||
fpi: data.intOr('fpiScore'),
|
||||
members: data.intOr('memberCount'),
|
||||
online: data.intOr('onlineCount'),
|
||||
isMine: id == myFandomId,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setPrimary(String fandomId) =>
|
||||
_backend.call('fnPrimaryFandomSet', {'fandomId': fandomId});
|
||||
|
||||
/// ZAP — 행성 성장 기여(B-23). 차감은 `fn-pop-spend` 가 한다.
|
||||
///
|
||||
/// `count` 를 함께 보내 **원장 한 행에 묶음 횟수를 남긴다**. 연타를 한 호출로
|
||||
/// 묶으면 `amount` 만으로는 횟수를 복원할 수 없어, MY 의 「Zap N 회」가 행 수로
|
||||
/// 계산되어 실제보다 작게 나온다.
|
||||
@override
|
||||
Future<ZapResult> zap({
|
||||
required String fandomId,
|
||||
required int count,
|
||||
required String clientSeq,
|
||||
}) async {
|
||||
final snapshot = await _backend.db.doc('config/policy').get();
|
||||
final zapPop = (snapshot.data() ?? const <String, dynamic>{}).intOr('zapPop', 10);
|
||||
final data = await _backend.call('fnPopSpend', {
|
||||
'popType': 'activity',
|
||||
'amount': zapPop * count,
|
||||
'count': count,
|
||||
'reasonCode': 'ZAP_CONTRIBUTION',
|
||||
'idempotencyKey': 'zap:$fandomId:$clientSeq',
|
||||
'fandomId': fandomId,
|
||||
'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;
|
||||
return ZapResult(
|
||||
contributedPop: contributedPop,
|
||||
txId: data['txId'] as String? ?? '',
|
||||
balanceAfter: (data['balanceAfter'] as num?)?.toInt() ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
GalaxyEvent _event(Map<String, dynamic> data) => GalaxyEvent(
|
||||
kind: data.strOr('kind') == 'eclipse'
|
||||
? GalaxyEventKind.eclipse
|
||||
: GalaxyEventKind.itemUsed,
|
||||
message: data.strOr('message'),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/play_banner.dart';
|
||||
import 'galaxy_data.dart';
|
||||
import '../../core/format.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 갤럭시 검색 — Figma `03_Wireframe` 「Galaxy_검색」(`353:217832`).
|
||||
///
|
||||
/// 화면 대조 원장 F2 의 11번. 명세 *돋보기 — 검색페이지 이동*.
|
||||
///
|
||||
/// Figma 검색 화면이 공통으로 갖는 것을 그대로 따랐다 —
|
||||
/// 「최근 검색어 / 전체 삭제 / 최근 검색어가 없습니다.」
|
||||
class GalaxySearchScreen extends StatefulWidget {
|
||||
const GalaxySearchScreen({
|
||||
super.key,
|
||||
this.repository = const SampleGalaxyRepository(),
|
||||
});
|
||||
|
||||
final GalaxyRepository repository;
|
||||
|
||||
@override
|
||||
State<GalaxySearchScreen> createState() => _GalaxySearchScreenState();
|
||||
}
|
||||
|
||||
class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot;
|
||||
final _controller = TextEditingController();
|
||||
|
||||
/// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다.
|
||||
final List<String> _recent = [];
|
||||
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snapshot = widget.repository.load();
|
||||
_controller.addListener(() => setState(() => _query = _controller.text));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit(String q) {
|
||||
final term = q.trim();
|
||||
if (term.isEmpty) return;
|
||||
setState(() {
|
||||
_recent
|
||||
..remove(term)
|
||||
..insert(0, term);
|
||||
if (_recent.length > 10) _recent.removeLast();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
// 시안 `Top Navigation`(54 · 좌우 16) — 뒤로 42 + 검색 칸 #282828 r10 높이 42.
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 54,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
title: Row(
|
||||
children: [
|
||||
const SizedBox.square(dimension: 42, child: BackButton()),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF282828),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/figma/icon-search-20.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: _submit,
|
||||
style: _t(16, AppColors.onCosmic, FontWeight.w500, 1.4),
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.galaxySearchHint,
|
||||
hintStyle: _t(
|
||||
16,
|
||||
const Color(0xFF6E6E6E),
|
||||
FontWeight.w500,
|
||||
1.4,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_query.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: _controller.clear,
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
color: Color(0xFF6E6E6E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FutureBuilder<GalaxySnapshot>(
|
||||
future: _snapshot,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final planets = snap.data?.planets ?? const <PlanetNode>[];
|
||||
if (_query.trim().isEmpty) {
|
||||
return _Recent(
|
||||
terms: _recent,
|
||||
onClear: () => setState(_recent.clear),
|
||||
onPick: (t) {
|
||||
_controller.text = t;
|
||||
_submit(t);
|
||||
},
|
||||
);
|
||||
}
|
||||
final hits = planets
|
||||
.where(
|
||||
(p) =>
|
||||
p.name.toLowerCase().contains(_query.trim().toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
if (hits.isEmpty) return const _NoHit();
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
itemCount: hits.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s2),
|
||||
itemBuilder: (context, i) => _Hit(planet: hits[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _t(double size, Color color, FontWeight weight, double height) =>
|
||||
TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
height: height,
|
||||
color: color,
|
||||
);
|
||||
|
||||
/// 최근 검색어 — 시안 `Frame 4241`(안쪽 16 · 간격 24): 배너, 「최근 검색어 16/600 ·
|
||||
/// 전체 삭제 14/500 #c2c2c2」, 비었으면 140 칸 가운데 「최근 검색어가 없습니다.」 14/600 #6e6e6e.
|
||||
class _Recent extends StatelessWidget {
|
||||
const _Recent({
|
||||
required this.terms,
|
||||
required this.onClear,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
final List<String> terms;
|
||||
final VoidCallback onClear;
|
||||
final ValueChanged<String> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView(
|
||||
// 시안 배너만 좌우 9(357 폭), 나머지는 16.
|
||||
padding: const EdgeInsets.fromLTRB(9, 16, 9, 16),
|
||||
children: [
|
||||
const PlayBanner(),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
child: _body(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _body(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.commonRecentSearches,
|
||||
style: _t(16, AppColors.onCosmic, FontWeight.w600, 20 / 16),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onClear,
|
||||
child: Text(
|
||||
context.l10n.commonClearAll,
|
||||
style: _t(14, const Color(0xFFC2C2C2), FontWeight.w500, 20 / 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
if (terms.isEmpty)
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.commonNoRecentSearches,
|
||||
style: _t(14, const Color(0xFF6E6E6E), FontWeight.w600, 20 / 14),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: AppSpace.s2,
|
||||
runSpacing: AppSpace.s2,
|
||||
children: [
|
||||
for (final term in terms)
|
||||
ActionChip(label: Text(term), onPressed: () => onPick(term)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _NoHit extends StatelessWidget {
|
||||
const _NoHit();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Text(
|
||||
context.l10n.galaxyNoHit,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Hit extends StatelessWidget {
|
||||
const _Hit({required this.planet});
|
||||
|
||||
final PlanetNode planet;
|
||||
|
||||
@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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/live_planet.dart';
|
||||
import '../../../core/data/planet_visual_state.dart';
|
||||
import '../galaxy_data.dart';
|
||||
import '../galaxy_motion.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 갤럭시 맵의 행성 배치와 그리기.
|
||||
///
|
||||
/// Figma 「갤럭시맵 화면」 명세 —
|
||||
/// *규칙 : 3d갤럭시 브라우징 줌인 줌아웃 · 행성 나의행성 한가운데*
|
||||
/// *동작 : 3d갤럭시 터치 이동 · 행성 터치시 화면 중심 전환이동*
|
||||
///
|
||||
/// 중심 행성([PlanetNode.isCenter] — 기본은 내 행성, Ranking View 는 FPI 1위)이 원점에
|
||||
/// 서고 나머지가 그 둘레를 돈다. 반지름은 순위를 따르므로 **위치 자체가 순위 정보**다 —
|
||||
/// 가까울수록 상위다. 내 행성은 어느 보기에서든 분홍 글로우 · 굵은 이름으로 드러난다.
|
||||
///
|
||||
/// 깊이감은 네 가지로 낸다(검토회신서 3절 *「브라우징·줌 및 공간 깊이감」*).
|
||||
/// 1. 궤도 타원을 눕힌다([GalaxyMotion.tilt]).
|
||||
/// 2. 궤도 앞쪽(화면 아래)의 행성을 크게, 뒤쪽을 작게([GalaxyMotion.depthScale]).
|
||||
/// 3. 뒤쪽을 옅게([GalaxyMotion.depthFade]) — 대기 원근.
|
||||
/// 4. 앞쪽 행성이 뒤쪽을 가리도록 **그리는 순서를 깊이로 정한다**.
|
||||
/// 별밭 시차는 화면 좌표라 여기가 아니라 [GalaxyStarfield] 가 가진다.
|
||||
class GalaxyField extends StatelessWidget {
|
||||
const GalaxyField({
|
||||
super.key,
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
this.onOpenHistory,
|
||||
required this.planets,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
required this.spin,
|
||||
required this.pulse,
|
||||
});
|
||||
|
||||
final PlanetWatch watchPlanet;
|
||||
final ValueChanged<PlanetNode>? onOpenHistory;
|
||||
final List<PlanetNode> planets;
|
||||
final PlanetNode? selected;
|
||||
final ValueChanged<PlanetNode> onSelect;
|
||||
|
||||
/// 궤도 회전 각도(rad). [GalaxyClock] 이 굴린다.
|
||||
final double spin;
|
||||
|
||||
/// 0~1 을 반복하는 값. 링 맥동에 쓴다.
|
||||
final double pulse;
|
||||
|
||||
/// 맵 논리 크기. [InteractiveViewer] 가 이 위를 훑는다.
|
||||
static const double extent = 900;
|
||||
|
||||
/// 가장 안쪽 궤도의 반지름.
|
||||
static const double innerRing = 120;
|
||||
|
||||
/// 궤도가 그대로 벌어질 때의 고리 간격 — 행성이 몇 개 없을 때 쓰는 값이다.
|
||||
static const double ringGap = 52;
|
||||
|
||||
/// 맵 가장자리에 남기는 여백. 행성 일러스트가 반지름의 1.6배까지 나가고
|
||||
/// 깊이 배율이 거기에 1.26배를 더 얹는다.
|
||||
static const double edgeMargin = 60;
|
||||
|
||||
/// 맵에서 가장 낮은 순위. 궤도 간격을 정하는 유일한 입력이다.
|
||||
static int outerRankOf(List<PlanetNode> planets) =>
|
||||
planets.fold(1, (m, p) => p.rank > m ? p.rank : m);
|
||||
|
||||
/// 궤도 반지름.
|
||||
///
|
||||
/// **간격은 행성 수를 따라 좁아진다.** [ringGap] 을 그대로 쓰면 8위부터 궤도가
|
||||
/// 맵 반쪽(`extent/2`)을 넘어 [Stack] 에 잘려 나간다 — 30개 중 20개, 100개 중
|
||||
/// 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);
|
||||
return innerRing + (rank - 1) * gap;
|
||||
}
|
||||
|
||||
/// 행성의 논리 좌표. 그리기·히트 테스트·HUD 가 같은 식을 써야 어긋나지 않는다.
|
||||
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);
|
||||
final base = (index * 2 * math.pi) / 5.4;
|
||||
// 바깥 궤도가 더 느리게 돈다. 같은 속도로 돌면 판때기 한 장이 도는 것처럼
|
||||
// 보여 깊이가 죽는다.
|
||||
final angle = base + spin * GalaxyMotion.orbitSpeedOf(p.rank);
|
||||
return Offset(
|
||||
extent / 2 + ring * math.cos(angle),
|
||||
extent / 2 + ring * math.sin(angle) * GalaxyMotion.tilt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 순위가 낮을수록 작다. **바닥을 두는 이유**는 원식(`26 - rank*1.4`)이
|
||||
/// 19위부터 음수가 되기 때문이다.
|
||||
/// ponytail: 바닥이 9 라 순위가 아주 낮은 구간은 크기가 같다. 100개처럼
|
||||
/// 빽빽한 맵에서 겹쳐 보이면 [ringOf] 의 간격에 크기를 묶는다.
|
||||
static double radiusOf(PlanetNode p) =>
|
||||
p.isCenter ? 34 : (26 - p.rank * 1.4).clamp(9.0, 26.0);
|
||||
|
||||
/// -1(뒤·화면 위) ~ +1(앞·화면 아래) 의 깊이.
|
||||
static double depthOf(Offset at) =>
|
||||
((at.dy - extent / 2) / (extent / 2 * GalaxyMotion.tilt)).clamp(-1.0, 1.0);
|
||||
|
||||
/// 2.5D 깊이 배율. 궤도 앞쪽일수록 크다.
|
||||
static double depthScaleOf(Offset at) =>
|
||||
1 + depthOf(at) * GalaxyMotion.depthScale;
|
||||
|
||||
/// 대기 원근 — 뒤쪽 행성이 옅어진다.
|
||||
static double depthOpacityOf(Offset at) =>
|
||||
1 - (1 - depthOf(at)) / 2 * GalaxyMotion.depthFade;
|
||||
|
||||
/// 행성이 화면에서 차지하는 반지름. HUD 가 이 위에 붙는다.
|
||||
static double screenRadiusOf(
|
||||
PlanetNode p,
|
||||
int index,
|
||||
double spin,
|
||||
int outerRank,
|
||||
) {
|
||||
final at = positionOf(p, index, spin, outerRank);
|
||||
return radiusOf(p) * depthScaleOf(at);
|
||||
}
|
||||
|
||||
/// 행성 일러스트. 내 행성은 홈과 같은 그림, 나머지는 시안 행성 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');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 앞에 있는 행성이 뒤를 가려야 입체로 읽힌다. 인덱스는 궤도 각도를 정하는
|
||||
// 값이라 바꿀 수 없으므로, 인덱스를 들고 다니면서 깊이로만 다시 세운다.
|
||||
final outerRank = outerRankOf(planets);
|
||||
final ordered = [
|
||||
for (final (i, p) in planets.indexed)
|
||||
(i, p, positionOf(p, i, spin, outerRank)),
|
||||
]..sort((a, b) => a.$3.dy.compareTo(b.$3.dy));
|
||||
|
||||
return SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 궤도선은 순위가 바뀔 때만 달라진다. 같은 층에 두면 궤도가 도는 매
|
||||
// 프레임 다시 래스터화되므로 RepaintBoundary 로 층을 갈라 한 번만 굽는다.
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
painter: _BackdropPainter(planets: planets, outerRank: outerRank),
|
||||
isComplex: true,
|
||||
willChange: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 터치 대상은 그림이 아니라 실제 위젯이다. CustomPaint 안에서 히트
|
||||
// 테스트를 손으로 계산하면 줌 배율이 바뀔 때마다 어긋난다.
|
||||
for (final (i, p, at) in ordered)
|
||||
_PlanetHit(
|
||||
// 유일한 것은 이름이 아니라 문서 ID 다 — 동명 팬덤이 둘이면
|
||||
// 이름으로는 둘 다 선택 상태로 그려진다. 깊이 순으로 자리를
|
||||
// 바꿔도 위젯이 살아남게 하는 키이기도 하다.
|
||||
key: ValueKey(p.id),
|
||||
planet: p,
|
||||
watchPlanet: watchPlanet,
|
||||
image: imageOf(p, i),
|
||||
center: at,
|
||||
radius: radiusOf(p) * depthScaleOf(at),
|
||||
opacity: depthOpacityOf(at),
|
||||
selected: selected?.id == p.id,
|
||||
onTap: () => onSelect(p),
|
||||
onOpenHistory: onOpenHistory == null ? null : () => onOpenHistory!(p),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanetHit extends StatelessWidget {
|
||||
final PlanetWatch watchPlanet;
|
||||
final VoidCallback? onOpenHistory;
|
||||
const _PlanetHit({
|
||||
required this.watchPlanet,
|
||||
this.onOpenHistory,
|
||||
super.key,
|
||||
required this.planet,
|
||||
required this.image,
|
||||
required this.center,
|
||||
required this.radius,
|
||||
required this.opacity,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PlanetNode planet;
|
||||
final String image;
|
||||
final Offset center;
|
||||
final double radius;
|
||||
final double opacity;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 일러스트는 구름·장식이 본체 밖까지 나가 있어 반지름의 3배로 그린다.
|
||||
// 터치 타깃도 그 크기다.
|
||||
final side = radius * 3.2;
|
||||
final glow = planet.isMine ? AppColors.cosmicAccent : AppColors.neonViolet;
|
||||
// 선택한 행성은 깊이와 무관하게 또렷하다 — HUD 와 한 정보군으로 읽혀야 한다.
|
||||
final fade = selected ? 1.0 : opacity;
|
||||
return Positioned(
|
||||
left: center.dx - side / 2,
|
||||
top: center.dy - side / 2,
|
||||
width: side,
|
||||
height: side,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.galaxyPlanetRank(planet.name, '${planet.rank}'),
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Opacity(
|
||||
opacity: fade,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 2. 글로우 아우라 — 선택하면 진해진다.
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
glow.withValues(alpha: selected ? 0.55 : 0.32),
|
||||
glow.withValues(alpha: 0),
|
||||
],
|
||||
stops: const [0.3, 0.75],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 3~5. 본체 일러스트(표면 장식·구름 포함).
|
||||
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(
|
||||
planet.name,
|
||||
style: TextStyle(
|
||||
fontSize: planet.isMine ? 13 : 11,
|
||||
color: selected
|
||||
? AppColors.cosmicAccent
|
||||
: AppColors.onCosmic,
|
||||
fontWeight: planet.isMine
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
shadows: const [Shadow(color: Colors.black, blurRadius: 6)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 움직이지 않는 배경 — 궤도선.
|
||||
///
|
||||
/// 궤도 반지름은 순위를 따르므로 순위가 바뀔 때만 다시 그리면 된다.
|
||||
/// 별밭은 여기 없다. 시차를 주려면 화면 좌표에 있어야 해서 [GalaxyStarfield] 로 뺐다.
|
||||
class _BackdropPainter extends CustomPainter {
|
||||
const _BackdropPainter({required this.planets, required this.outerRank});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
final int outerRank;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final orbit = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = AppColors.cosmicOutline.withValues(alpha: 0.5);
|
||||
for (final p in planets.where((p) => !p.isCenter)) {
|
||||
final ring = GalaxyField.ringOf(p.rank, outerRank);
|
||||
canvas.drawOval(
|
||||
Rect.fromCenter(
|
||||
center: center,
|
||||
width: ring * 2,
|
||||
height: ring * 2 * GalaxyMotion.tilt,
|
||||
),
|
||||
orbit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 순위가 그대로면 다시 그리지 않는다 — 궤도 배치를 정하는 유일한 입력이다.
|
||||
@override
|
||||
bool shouldRepaint(_BackdropPainter old) =>
|
||||
old.planets.length != planets.length ||
|
||||
old.outerRank != outerRank ||
|
||||
!_sameRanks(old.planets, planets);
|
||||
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 별밭 — 맵보다 **덜** 움직여서 멀리 있어 보이게 하는 층(시차).
|
||||
///
|
||||
/// 맵과 같이 움직이면 별이 맵에 붙은 무늬가 된다. 화면 좌표에 두고
|
||||
/// [GalaxyMotion.parallax] 만큼만 따라오게 해야 「그 뒤의 공간」이 된다.
|
||||
/// 별 자체는 [RepaintBoundary] 안에서 한 번만 굽고 이동·확대는 합성으로 처리한다
|
||||
/// — 매 프레임 별 220개를 다시 그리면 팬·줌이 끊긴다.
|
||||
class GalaxyStarfield extends StatelessWidget {
|
||||
const GalaxyStarfield({super.key, required this.view});
|
||||
|
||||
/// 맵의 현재 변환. 이동량과 배율을 여기서 읽는다.
|
||||
final Matrix4 view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = view.getTranslation();
|
||||
final scale = view.getMaxScaleOnAxis();
|
||||
const limit = GalaxyMotion.parallaxLimit;
|
||||
final dx = (t.x * GalaxyMotion.parallax).clamp(-limit, limit);
|
||||
final dy = (t.y * GalaxyMotion.parallax).clamp(-limit, limit);
|
||||
return ClipRect(
|
||||
child: Transform.translate(
|
||||
offset: Offset(dx, dy),
|
||||
child: Transform.scale(
|
||||
scale: 1 + (scale - 1) * GalaxyMotion.parallaxZoom,
|
||||
child: OverflowBox(
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
maxWidth: double.infinity,
|
||||
maxHeight: double.infinity,
|
||||
child: SizedBox(
|
||||
// 시차로 밀리는 만큼 화면보다 크게 그려 둔다.
|
||||
width: MediaQuery.sizeOf(context).width + limit * 2,
|
||||
height: MediaQuery.sizeOf(context).height + limit * 2,
|
||||
child: const RepaintBoundary(
|
||||
child: CustomPaint(painter: _StarfieldPainter()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StarfieldPainter extends CustomPainter {
|
||||
const _StarfieldPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// 좌표는 고정 시드로 뽑는다. 매번 새로 뽑으면 프레임마다 별이 튄다.
|
||||
final rnd = math.Random(20260831);
|
||||
for (var i = 0; i < 220; i++) {
|
||||
// 먼 별은 작고 옅다 — 별밭 안에서도 층이 진다.
|
||||
final depth = rnd.nextDouble();
|
||||
canvas.drawCircle(
|
||||
Offset(rnd.nextDouble() * size.width, rnd.nextDouble() * size.height),
|
||||
0.4 + depth * 1.1,
|
||||
Paint()
|
||||
..color = AppColors.onCosmicVariant.withValues(
|
||||
alpha: 0.14 + depth * 0.24,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_StarfieldPainter old) => false;
|
||||
}
|
||||
|
||||
/// 배경 층의 그리기 비용. 이 층은 프레임마다 도는 것이 아니라 한 번만 굽는다.
|
||||
@visibleForTesting
|
||||
CustomPainter galaxyBackdropPainterForTest({
|
||||
required List<PlanetNode> planets,
|
||||
}) => _BackdropPainter(
|
||||
planets: planets,
|
||||
outerRank: GalaxyField.outerRankOf(planets),
|
||||
);
|
||||
|
||||
/// 별밭 층의 그리기 비용.
|
||||
@visibleForTesting
|
||||
CustomPainter galaxyStarfieldPainterForTest() => const _StarfieldPainter();
|
||||
@@ -0,0 +1,802 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/planet_25d.dart';
|
||||
import '../../../core/format.dart';
|
||||
import '../galaxy_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// HOME GALAXY 미니맵 — 발주사 프로토타입 v6.2(`UZU HUD (2).html`)
|
||||
/// 의 3D 장면을 [CustomPainter] 로 옮긴 것(정책 원장 C-49).
|
||||
///
|
||||
/// - 카메라·궤도·행성 크기·HUD 배치는 프로토타입 수식 그대로다. 게이지 값은 프로토타입
|
||||
/// 기본값(Mass x1.5 · Orbit x1.0 · HUD x1.0 · Camera 2000 · Rotation 0°)으로 고정한다.
|
||||
/// - **게이지 조절 8개는 넣지 않는다(C-51).**
|
||||
/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49).
|
||||
/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다.
|
||||
///
|
||||
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
|
||||
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
|
||||
class GalaxyMiniMap extends StatefulWidget {
|
||||
const GalaxyMiniMap({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onOpen,
|
||||
this.size = 140,
|
||||
});
|
||||
|
||||
final GalaxySnapshot data;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
/// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시).
|
||||
final double size;
|
||||
|
||||
@override
|
||||
State<GalaxyMiniMap> createState() => _GalaxyMiniMapState();
|
||||
}
|
||||
|
||||
class _GalaxyMiniMapState extends State<GalaxyMiniMap>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _painter = GalaxyMiniMapPainter();
|
||||
late final Ticker _ticker = createTicker(_tick);
|
||||
Duration _last = Duration.zero;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_painter.update(widget.data.planets);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final motion =
|
||||
Planet25D.animationsEnabled && !MediaQuery.disableAnimationsOf(context);
|
||||
if (motion && !_ticker.isActive) {
|
||||
_last = Duration.zero;
|
||||
_ticker.start();
|
||||
} else if (!motion) {
|
||||
_ticker.stop();
|
||||
_painter.settle();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GalaxyMiniMap old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (identical(old.data, widget.data)) return;
|
||||
_painter.update(widget.data.planets);
|
||||
if (!_ticker.isActive) _painter.settle();
|
||||
}
|
||||
|
||||
void _tick(Duration now) {
|
||||
final dt = _last == Duration.zero
|
||||
? 0.0
|
||||
: (now - _last).inMicroseconds / Duration.microsecondsPerSecond;
|
||||
_last = now;
|
||||
// 백그라운드 복귀처럼 프레임을 오래 건너뛴 뒤 한 번에 튀지 않게 한다.
|
||||
_painter.advance(dt.clamp(0.0, 1 / 20));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
_painter.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mine = widget.data.mine;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: [
|
||||
if (mine != null) ...GalaxyMiniMapPainter.hudLines(mine) else 'GALAXY',
|
||||
context.l10n.galaxyMapOpen,
|
||||
].join(' · '),
|
||||
onTap: widget.onOpen,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onHorizontalDragUpdate: (d) {
|
||||
_painter.turn(d.delta.dx / widget.size * math.pi);
|
||||
if (!_ticker.isActive) _painter.settle();
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: ExcludeSemantics(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 매 프레임 다시 그리므로 HOME 히어로와 레이어를 가른다.
|
||||
RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
size: Size.square(widget.size),
|
||||
painter: _painter,
|
||||
isComplex: true,
|
||||
willChange: true,
|
||||
),
|
||||
),
|
||||
// 잉크는 불투명 장면 위에 얹어야 보인다.
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
excludeFromSemantics: true,
|
||||
onTap: widget.onOpen,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프로토타입 색(C-49 — 테마 결정 전까지 유지).
|
||||
const _palette = [
|
||||
Color(0xFF00F2FF),
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFF10B981),
|
||||
Color(0xFFF43F5E),
|
||||
Color(0xFFF59E0B),
|
||||
];
|
||||
const _emerald = Color(0xFF10B981);
|
||||
|
||||
/// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다.
|
||||
/// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다.
|
||||
Color _planetColor(PlanetNode p, int fpiIndex) =>
|
||||
planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length];
|
||||
|
||||
/// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51).
|
||||
const _massVolume = 1.5;
|
||||
const _orbitSpacing = 450 * _massVolume * 1.0;
|
||||
const _camY = 2000.0;
|
||||
const _camZ = 3500.0;
|
||||
final _camLen = math.sqrt(_camY * _camY + _camZ * _camZ);
|
||||
|
||||
/// 카메라가 원점을 본다 — 전방 (0, fy, fz) · 위 (0, uy, uz) · 오른쪽 (1, 0, 0).
|
||||
final _fy = -_camY / _camLen;
|
||||
final _fz = -_camZ / _camLen;
|
||||
final _uy = _camZ / _camLen;
|
||||
final _uz = -_camY / _camLen;
|
||||
final _tanHalfFov = math.tan(35 * math.pi / 180);
|
||||
|
||||
/// 프로토타입 HUD 텍스처 크기.
|
||||
const _hudW = 512.0;
|
||||
const _hudH = 256.0;
|
||||
|
||||
/// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로.
|
||||
double _px(double w) => w < 1 ? 0 : w;
|
||||
|
||||
/// 60fps 기준 한 프레임 lerp 계수 [k] 를 경과 시간에 맞춘다.
|
||||
double _lerpK(double k, double dt) => 1 - math.pow(1 - k, dt * 60).toDouble();
|
||||
|
||||
class _Body {
|
||||
_Body(this.id, this.phase);
|
||||
|
||||
final String id;
|
||||
|
||||
/// 프로토타입은 궤도 위상에 행성 id(1~5)를 더한다. 여기서는 처음 들어온 순번.
|
||||
final double phase;
|
||||
|
||||
late PlanetNode node;
|
||||
Object? _key;
|
||||
double share = 0;
|
||||
double rad = 0;
|
||||
double target = 0;
|
||||
double wx = 0;
|
||||
double wz = 0;
|
||||
double depth = 0;
|
||||
double hudW = 0;
|
||||
ui.Picture? hud;
|
||||
ui.Picture? sphere;
|
||||
Float32List sat = Float32List(0);
|
||||
Float32List satOut = Float32List(0);
|
||||
|
||||
/// 프로토타입 `15 + fpi / 200` — fpi 는 슬라이더 최대 5000 척도로 옮긴 값.
|
||||
double get baseR => 15 + share * 25;
|
||||
int get ringCount => node.rank == 1 ? 3 : (node.rank <= 3 ? 2 : 1);
|
||||
|
||||
void set(PlanetNode p, Color color, double s) {
|
||||
node = p;
|
||||
target = (p.rank - 1) * _orbitSpacing;
|
||||
final key = (p.name, p.rank, p.fpi, p.online, color, s);
|
||||
if (key == _key) return;
|
||||
_key = key;
|
||||
share = s;
|
||||
dispose();
|
||||
hud = _hudPicture(p, color, s);
|
||||
sphere = _spherePicture(color);
|
||||
// 프로토타입 위성 수 `fpi / 10`.
|
||||
final n = (s * 5000 / 10).floor();
|
||||
final rnd = math.Random(p.id.hashCode);
|
||||
sat = Float32List(n * 3);
|
||||
satOut = Float32List(n * 2);
|
||||
for (var j = 0; j < n; j++) {
|
||||
final r = 1.8 + rnd.nextDouble() * 2.5;
|
||||
final theta = rnd.nextDouble() * math.pi * 2;
|
||||
final phi = rnd.nextDouble() * math.pi;
|
||||
sat[j * 3] = r * math.sin(phi) * math.cos(theta);
|
||||
sat[j * 3 + 1] = r * math.sin(phi) * math.sin(theta);
|
||||
sat[j * 3 + 2] = r * math.cos(phi);
|
||||
}
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
Color color = _emerald;
|
||||
|
||||
void dispose() {
|
||||
hud?.dispose();
|
||||
sphere?.dispose();
|
||||
hud = null;
|
||||
sphere = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프로토타입 `generateHUDTexture` — 512×256 텍스처를 한 번만 그려 두고 매 프레임 변환해 찍는다.
|
||||
ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
const box = Rect.fromLTWH(10, 10, 492, 236);
|
||||
canvas.drawRect(
|
||||
box,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 4
|
||||
..color = color.withValues(alpha: 0.8),
|
||||
);
|
||||
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
|
||||
canvas.save();
|
||||
canvas.clipRect(box);
|
||||
final lines = GalaxyMiniMapPainter.hudLines(p);
|
||||
for (final (text, baseline, size, weight, c) in [
|
||||
(lines[0], 80.0, 50.0, FontWeight.w800, color),
|
||||
(lines[1], 120.0, 24.0, FontWeight.w700, const Color(0xB3FFFFFF)),
|
||||
(lines[2], 180.0, 40.0, FontWeight.w800, _emerald),
|
||||
(lines[3], 220.0, 24.0, FontWeight.w700, const Color(0xE6FFFFFF)),
|
||||
]) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
color: c,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
tp.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
40,
|
||||
baseline - tp.computeDistanceToActualBaseline(TextBaseline.alphabetic),
|
||||
),
|
||||
);
|
||||
tp.dispose();
|
||||
}
|
||||
canvas.restore();
|
||||
// 프로토타입 막대 `160 * fpi / 5000` — 1위 대비 비율로 옮겼다.
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(450, 40, 20, 160 * share),
|
||||
Paint()..color = _emerald,
|
||||
);
|
||||
return rec.endRecording();
|
||||
}
|
||||
|
||||
/// 반지름 1 단위의 광륜(6R, 가산 합성) + 발광 구체.
|
||||
ui.Picture _spherePicture(Color c) {
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
canvas.drawCircle(
|
||||
Offset.zero,
|
||||
3,
|
||||
Paint()
|
||||
..blendMode = BlendMode.plus
|
||||
..shader = ui.Gradient.radial(
|
||||
Offset.zero,
|
||||
3,
|
||||
[
|
||||
c.withValues(alpha: 0.4),
|
||||
c.withValues(alpha: 0.15),
|
||||
c.withValues(alpha: 0),
|
||||
],
|
||||
const [0, 0.2, 0.6],
|
||||
),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset.zero,
|
||||
1,
|
||||
Paint()
|
||||
..shader = ui.Gradient.radial(
|
||||
const Offset(-0.35, -0.35),
|
||||
1.35,
|
||||
[
|
||||
Color.lerp(c, const Color(0xFFFFFFFF), 0.6)!,
|
||||
c,
|
||||
Color.lerp(c, const Color(0xFF000000), 0.3)!,
|
||||
],
|
||||
const [0, 0.45, 1],
|
||||
),
|
||||
);
|
||||
return rec.endRecording();
|
||||
}
|
||||
|
||||
/// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다.
|
||||
class GalaxyMiniMapPainter extends CustomPainter {
|
||||
GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0));
|
||||
GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame);
|
||||
|
||||
final ValueNotifier<int> _frame;
|
||||
|
||||
/// 행성 위 HUD 네 줄 — 프로토타입 텍스처와 같은 문구.
|
||||
static List<String> hudLines(PlanetNode p) => [
|
||||
p.name.toUpperCase(),
|
||||
'RANK #${p.rank}',
|
||||
'FPI: ${p.fpi}',
|
||||
'LIVE: ${formatPop(p.online)}',
|
||||
];
|
||||
|
||||
final _bodies = <String, _Body>{};
|
||||
final _order = <_Body>[];
|
||||
_Body? _mine;
|
||||
|
||||
double _time = 0;
|
||||
double _scale = 1;
|
||||
double _rot = 0;
|
||||
double _rotTarget = 0;
|
||||
double _gx = 0;
|
||||
double _gz = 0;
|
||||
|
||||
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
|
||||
static const maxPlanets = 10;
|
||||
|
||||
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비).
|
||||
static const hudFloor = 0.7;
|
||||
|
||||
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
|
||||
static const fullHudMin = 280.0;
|
||||
|
||||
/// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄.
|
||||
static const compactNamePt = 11.0;
|
||||
static const compactLinePt = 9.0;
|
||||
|
||||
/// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`.
|
||||
static String compactLine(PlanetNode p) =>
|
||||
'#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}';
|
||||
|
||||
ui.Picture? _compact;
|
||||
Object? _compactKey;
|
||||
Size _compactSize = Size.zero;
|
||||
|
||||
/// 마지막으로 만든 간략 카드의 줄과 글자 크기. 없으면 비어 있다.
|
||||
@visibleForTesting
|
||||
List<(String, double)> compactText = const [];
|
||||
|
||||
/// 내 행성 간략 카드. 데이터·최대 폭이 바뀔 때만 다시 만든다.
|
||||
ui.Picture _compactCard(_Body b, double maxW) {
|
||||
final key = (b._key, maxW);
|
||||
if (key == _compactKey && _compact != null) return _compact!;
|
||||
_compact?.dispose();
|
||||
_compactKey = key;
|
||||
const pad = 6.0, barW = 3.0, gap = 4.0;
|
||||
final textMax = maxW - pad * 2 - barW - gap;
|
||||
TextPainter line(String text, double pt, FontWeight weight, Color c) =>
|
||||
TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: pt,
|
||||
fontWeight: weight,
|
||||
height: 1.2,
|
||||
color: c,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 2,
|
||||
ellipsis: '…',
|
||||
)..layout(maxWidth: textMax);
|
||||
final name = line(
|
||||
b.node.name.toUpperCase(),
|
||||
compactNamePt,
|
||||
FontWeight.w800,
|
||||
b.color,
|
||||
);
|
||||
// 좁으면 LIVE 앞에서 둘째 줄이 한 번 꺾인다(글자 크기는 줄이지 않는다).
|
||||
final stats = line(
|
||||
compactLine(b.node),
|
||||
compactLinePt,
|
||||
FontWeight.w700,
|
||||
const Color(0xE6FFFFFF),
|
||||
);
|
||||
final textW = math.max(name.width, stats.width);
|
||||
final w = pad * 2 + textW + gap + barW;
|
||||
final h = pad * 2 + name.height + 2 + stats.height;
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
final box = Rect.fromLTWH(0, 0, w, h);
|
||||
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
|
||||
canvas.drawRect(
|
||||
box,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = b.color.withValues(alpha: 0.8),
|
||||
);
|
||||
name.paint(canvas, const Offset(pad, pad));
|
||||
stats.paint(canvas, Offset(pad, pad + name.height + 2));
|
||||
final barH = h - pad * 2;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(
|
||||
w - pad - barW,
|
||||
pad + barH * (1 - b.share),
|
||||
barW,
|
||||
barH * b.share,
|
||||
),
|
||||
Paint()..color = _emerald,
|
||||
);
|
||||
compactText = [
|
||||
(b.node.name.toUpperCase(), compactNamePt),
|
||||
(compactLine(b.node), compactLinePt),
|
||||
];
|
||||
name.dispose();
|
||||
stats.dispose();
|
||||
_compactSize = Size(w, h);
|
||||
return _compact = rec.endRecording();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
double get rotation => _rot;
|
||||
|
||||
/// 마지막 프레임에 그린 HUD 카드 폭(논리 px). 안 그렸으면 0.
|
||||
@visibleForTesting
|
||||
double? hudWidthOf(String id) => _bodies[id]?.hudW;
|
||||
|
||||
/// 행성에 칠한 색. 없으면 null.
|
||||
@visibleForTesting
|
||||
Color? colorOf(String id) => _bodies[id]?.color;
|
||||
|
||||
/// 행성의 현재 궤도 반지름(월드 단위). 없으면 null.
|
||||
@visibleForTesting
|
||||
double? orbitOf(String id) => _bodies[id]?.rad;
|
||||
|
||||
void update(List<PlanetNode> planets) {
|
||||
// FPI 상위 [maxPlanets] 개 + 내 행성.
|
||||
final byFpi = [...planets]..sort((a, b) => b.fpi.compareTo(a.fpi));
|
||||
final shown = byFpi.take(maxPlanets).toList();
|
||||
final mine = planets.where((p) => p.isMine).firstOrNull;
|
||||
if (mine != null && !shown.contains(mine)) shown.add(mine);
|
||||
|
||||
final top = shown.fold<int>(0, (m, p) => math.max(m, p.fpi));
|
||||
for (final (i, p) in shown.indexed) {
|
||||
final share = top <= 0 ? 0.0 : (p.fpi / top).clamp(0.0, 1.0);
|
||||
(_bodies[p.id] ??= _Body(
|
||||
p.id,
|
||||
_bodies.length + 1.0,
|
||||
)).set(p, _planetColor(p, i), share);
|
||||
}
|
||||
final ids = {for (final p in shown) p.id};
|
||||
_bodies.removeWhere((id, b) {
|
||||
if (ids.contains(id)) return false;
|
||||
b.dispose();
|
||||
return true;
|
||||
});
|
||||
_order
|
||||
..clear()
|
||||
..addAll(_bodies.values);
|
||||
_mine = _order.where((b) => b.node.isMine).firstOrNull;
|
||||
_place(0);
|
||||
_frame.value++;
|
||||
}
|
||||
|
||||
void turn(double radians) =>
|
||||
_rotTarget = (_rotTarget + radians).clamp(-math.pi, math.pi);
|
||||
|
||||
/// 움직임 없이 목표 상태로 바로 놓는다(애니메이션 끔 · 테스트).
|
||||
void settle() {
|
||||
_scale = _massVolume;
|
||||
_rot = _rotTarget;
|
||||
for (final b in _order) {
|
||||
b.rad = b.target;
|
||||
}
|
||||
_place(1);
|
||||
_frame.value++;
|
||||
}
|
||||
|
||||
/// 프로토타입 `animate()` 한 프레임 — lerp 계수는 60fps 기준 값을 [dt] 에 맞춘다.
|
||||
void advance(double dt) {
|
||||
_time += dt;
|
||||
_scale += (_massVolume - _scale) * _lerpK(0.05, dt);
|
||||
final k = _lerpK(0.03, dt);
|
||||
for (final b in _order) {
|
||||
b.rad += (b.target - b.rad) * k;
|
||||
}
|
||||
_rot += (_rotTarget - _rot) * _lerpK(0.05, dt);
|
||||
_place(_lerpK(0.08, dt));
|
||||
_frame.value++;
|
||||
}
|
||||
|
||||
/// 궤도 위치 → 월드 좌표. 장면은 내 행성을 가운데로 따라간다(계수 [follow]).
|
||||
void _place(double follow) {
|
||||
final c = math.cos(_rot);
|
||||
final s = math.sin(_rot);
|
||||
if (_mine case final m?) {
|
||||
final a = _angle(m);
|
||||
final lx = math.cos(a) * m.rad;
|
||||
final lz = math.sin(a) * m.rad;
|
||||
// 프로토타입은 회전 전 좌표로 따라간다. 회전 중에도 내 행성이 가운데 있도록 회전 뒤 좌표를 쓴다.
|
||||
_gx += (-(lx * c + lz * s) - _gx) * follow;
|
||||
_gz += (-(-lx * s + lz * c) - _gz) * follow;
|
||||
}
|
||||
for (final b in _order) {
|
||||
final a = _angle(b);
|
||||
final lx = math.cos(a) * b.rad;
|
||||
final lz = math.sin(a) * b.rad;
|
||||
b.wx = lx * c + lz * s + _gx;
|
||||
b.wz = -lx * s + lz * c + _gz;
|
||||
}
|
||||
}
|
||||
|
||||
/// 프로토타입 `time * speed + id` — time = ms × 0.0005, speed = 0.8 / (rank + 1).
|
||||
double _angle(_Body b) => _time * 0.5 * 0.8 / (b.node.rank + 1) + b.phase;
|
||||
|
||||
// ── 투영 결과(할당 없이 필드로 돌려준다) ──
|
||||
double _sx = 0;
|
||||
double _sy = 0;
|
||||
double _sd = 0;
|
||||
|
||||
void _project(double x, double y, double z, double focal, Size size) {
|
||||
final vy = y - _camY;
|
||||
final vz = z - _camZ;
|
||||
_sd = vy * _fy + vz * _fz;
|
||||
_sx = size.width / 2 + x / _sd * focal;
|
||||
_sy = size.height / 2 - (vy * _uy + vz * _uz) / _sd * focal;
|
||||
}
|
||||
|
||||
/// 수평 원이 카메라에서 납작해 보이는 비율.
|
||||
double _fore(double x, double z) {
|
||||
final dz = z - _camZ;
|
||||
return _camY / math.sqrt(x * x + _camY * _camY + dz * dz);
|
||||
}
|
||||
|
||||
Size _bgSize = Size.zero;
|
||||
ui.Picture? _bg;
|
||||
Paint? _scan;
|
||||
final _stroke = Paint()..style = PaintingStyle.stroke;
|
||||
final _fill = Paint();
|
||||
|
||||
/// 별밭·성운 — 장면 밖(갤럭시 그룹 밖)이라 카메라가 고정이면 크기마다 한 번만 그린다.
|
||||
/// ponytail: 프로토타입의 별 회전(0.0001rad/프레임)은 생략 — 분당 0.4° 라 미니맵에서 보이지 않는다.
|
||||
void _buildBackground(Size size, double focal) {
|
||||
_bgSize = size;
|
||||
_bg?.dispose();
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
canvas.drawRect(
|
||||
Offset.zero & size,
|
||||
Paint()..color = const Color(0xFF000000),
|
||||
);
|
||||
final rnd = math.Random(62);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
final x = (rnd.nextDouble() - 0.5) * 2000;
|
||||
final y = (rnd.nextDouble() - 0.5) * 500;
|
||||
final z = (rnd.nextDouble() - 0.5) * 2000;
|
||||
_project(x, y, z, focal, size);
|
||||
if (_sd < 1) continue;
|
||||
final r = 1500 * focal / _sd;
|
||||
final center = Offset(_sx, _sy);
|
||||
canvas.save();
|
||||
canvas.translate(_sx, _sy);
|
||||
canvas.scale(1, _fore(x, z));
|
||||
canvas.translate(-_sx, -_sy);
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
r,
|
||||
Paint()
|
||||
..blendMode = BlendMode.plus
|
||||
..shader = ui.Gradient.radial(
|
||||
center,
|
||||
r,
|
||||
const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)],
|
||||
const [0, 0.4, 0.8],
|
||||
),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
// 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다.
|
||||
final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0);
|
||||
final pts = <double>[];
|
||||
for (var i = 0; i < 8000; i++) {
|
||||
final x = (rnd.nextDouble() - 0.5) * 15000;
|
||||
final y = (rnd.nextDouble() - 0.5) * 15000;
|
||||
final z = (rnd.nextDouble() - 0.5) * 15000;
|
||||
final pick = rnd.nextDouble();
|
||||
_project(x, y, z, focal, size);
|
||||
if (_sd < 1 || pick > keep) continue;
|
||||
if (_sx < 0 || _sy < 0 || _sx > size.width || _sy > size.height) continue;
|
||||
pts
|
||||
..add(_sx)
|
||||
..add(_sy);
|
||||
}
|
||||
canvas.drawRawPoints(
|
||||
ui.PointMode.points,
|
||||
Float32List.fromList(pts),
|
||||
Paint()
|
||||
..strokeWidth = 0
|
||||
..color = const Color(0xCCBFBFFF),
|
||||
);
|
||||
_bg = rec.endRecording();
|
||||
_scan = Paint()
|
||||
..shader = ui.Gradient.linear(
|
||||
Offset.zero,
|
||||
const Offset(0, 4),
|
||||
const [Color(0x0010B981), Color(0x1410B981), Color(0x0010B981)],
|
||||
const [0, 0.5, 1],
|
||||
);
|
||||
}
|
||||
|
||||
static int _farFirst(_Body a, _Body b) => b.depth.compareTo(a.depth);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final focal = size.height / 2 / _tanHalfFov;
|
||||
if (size != _bgSize) _buildBackground(size, focal);
|
||||
canvas.drawPicture(_bg!);
|
||||
|
||||
// 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/100, 흰색 15%.
|
||||
_project(_gx, 0, _gz, focal, size);
|
||||
final gx = _sx, gy = _sy, gd = _sd;
|
||||
final gFore = _fore(_gx, _gz);
|
||||
_stroke.color = const Color(0x26FFFFFF);
|
||||
for (final b in _order) {
|
||||
// ponytail: 카메라 평면에 걸치는 큰 궤도는 건너뛴다 — 원근 타원 근사가 깨지는 구간.
|
||||
if (b.rad < 1 || gd - b.rad < 100) continue;
|
||||
final rx = b.rad * focal / gd;
|
||||
_stroke.strokeWidth = _px(b.rad / 100 * focal / gd);
|
||||
canvas.drawOval(
|
||||
Rect.fromCenter(
|
||||
center: Offset(gx, gy),
|
||||
width: rx * 2,
|
||||
height: rx * 2 * gFore,
|
||||
),
|
||||
_stroke,
|
||||
);
|
||||
}
|
||||
|
||||
for (final b in _order) {
|
||||
_project(b.wx, 0, b.wz, focal, size);
|
||||
b.depth = _sd;
|
||||
}
|
||||
_order.sort(_farFirst);
|
||||
|
||||
final satRot = -1.8 * _time; // 프로토타입 -0.03rad/프레임
|
||||
final sc = math.cos(satRot), ss = math.sin(satRot);
|
||||
for (final b in _order) {
|
||||
final r = b.baseR * _scale;
|
||||
// 근평면 — 카메라에 반지름 2배 이내로 붙은 행성은 광륜·띠 투영이 뒤집히므로 뺀다.
|
||||
if (b.depth < r * 2) continue;
|
||||
_project(b.wx, 0, b.wz, focal, size);
|
||||
final px = _sx, py = _sy, d = _sd;
|
||||
|
||||
// 위성 — 행성 반지름 1.8~4.3 배 구각.
|
||||
final sat = b.sat, out = b.satOut;
|
||||
for (var j = 0, n = sat.length ~/ 3; j < n; j++) {
|
||||
final x = sat[j * 3] * r,
|
||||
y = sat[j * 3 + 1] * r,
|
||||
z = sat[j * 3 + 2] * r;
|
||||
_project(
|
||||
b.wx + x * sc + z * ss,
|
||||
y,
|
||||
b.wz - x * ss + z * sc,
|
||||
focal,
|
||||
size,
|
||||
);
|
||||
// 카메라 뒤 점은 화면 밖으로 보낸다(버퍼 길이는 그대로).
|
||||
final behind = _sd < 1;
|
||||
out[j * 2] = behind ? -1e5 : _sx;
|
||||
out[j * 2 + 1] = behind ? -1e5 : _sy;
|
||||
}
|
||||
if (out.isNotEmpty) {
|
||||
_fill
|
||||
..strokeWidth = 0
|
||||
..blendMode = BlendMode.plus
|
||||
..color = b.color.withValues(
|
||||
alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0),
|
||||
);
|
||||
canvas.drawRawPoints(ui.PointMode.points, out, _fill);
|
||||
_fill.blendMode = BlendMode.srcOver;
|
||||
}
|
||||
|
||||
// 광륜 + 구체.
|
||||
final rPx = r * focal / d;
|
||||
canvas.save();
|
||||
canvas.translate(px, py);
|
||||
canvas.scale(rPx);
|
||||
canvas.drawPicture(b.sphere!);
|
||||
canvas.restore();
|
||||
|
||||
// 행성 띠 — 1위 3줄, 2~3위 2줄, 나머지 1줄.
|
||||
final fore = _fore(b.wx, b.wz);
|
||||
for (var i = 0; i < b.ringCount; i++) {
|
||||
final inner = b.baseR * (1.6 + i * 0.15) * _scale;
|
||||
final thick = b.baseR * 0.03 * _scale;
|
||||
final rx = (inner + thick / 2) * focal / d;
|
||||
_stroke
|
||||
..strokeWidth = _px(thick * focal / d)
|
||||
..color = b.color.withValues(alpha: 0.3 / (i + 1));
|
||||
canvas.drawOval(
|
||||
Rect.fromCenter(
|
||||
center: Offset(px, py),
|
||||
width: rx * 2,
|
||||
height: rx * 2 * fore,
|
||||
),
|
||||
_stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HUD — 깊이 검사 없이 맨 위(프로토타입 depthTest: false).
|
||||
final f = math.pow(_scale, 0.22).toDouble();
|
||||
final hudHeight = 57.5 * f;
|
||||
// [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만
|
||||
// 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만).
|
||||
final full = size.width >= fullHudMin;
|
||||
for (final b in _order) {
|
||||
b.hudW = 0;
|
||||
if (!full && !identical(b, _mine)) continue;
|
||||
if (b.depth < b.baseR * _scale * 2) continue;
|
||||
final y = _scale * (b.baseR * _scale + hudHeight / 2 + 2);
|
||||
_project(b.wx, y, b.wz, focal, size);
|
||||
if (_sd < 1) continue;
|
||||
final w = 115 * f * _scale * focal / _sd;
|
||||
if (full) {
|
||||
b.hudW = w;
|
||||
canvas.save();
|
||||
canvas.translate(_sx - w / 2, _sy - w / 4);
|
||||
canvas.scale(w / _hudW, w / 2 / _hudH);
|
||||
canvas.drawPicture(b.hud!);
|
||||
canvas.restore();
|
||||
continue;
|
||||
}
|
||||
final card = _compactCard(b, size.width * hudFloor);
|
||||
b.hudW = _compactSize.width;
|
||||
canvas.save();
|
||||
// 아래 변은 프로토타입 카드 자리(행성 바로 위)에 두고 위로 키운다 — 행성을 가리지 않게.
|
||||
canvas.translate(
|
||||
_sx - _compactSize.width / 2,
|
||||
_sy + w / 4 - _compactSize.height,
|
||||
);
|
||||
canvas.drawPicture(card);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
// 스캔라인 — 6초 주기로 위에서 아래로.
|
||||
final scanY = -4 + (_time % 6) / 6 * (size.height + 4);
|
||||
canvas.save();
|
||||
canvas.translate(0, scanY);
|
||||
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, 4), _scan!);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(GalaxyMiniMapPainter old) => !identical(old, this);
|
||||
|
||||
void dispose() {
|
||||
for (final b in _order) {
|
||||
b.dispose();
|
||||
}
|
||||
_bg?.dispose();
|
||||
_compact?.dispose();
|
||||
_frame.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
const _bolt = AssetImage('assets/figma/icon-bolt.png');
|
||||
const _item = AssetImage('assets/figma/icon-item.png');
|
||||
const _zapGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF4BC2AE), Color(0xFF4F98C8), Color(0xFF5669DC)],
|
||||
);
|
||||
|
||||
/// Zap — Figma 「Zap 버튼」 명세 그대로.
|
||||
///
|
||||
/// *요소 : Zap 기본 · Zap 옵션 · 기여 · 아이템 · 닫기*
|
||||
///
|
||||
/// **「자동(오토 도네이션)」은 두지 않는다.** 별첨 1 ① 시나리오 4 가 적었으나 별첨 B
|
||||
/// 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항).
|
||||
/// *규칙 : 화면 하단 위치 고정 · 디폴트 Zap 기본 노출 ·*
|
||||
/// ***행성/성운 가입 또는 생성한 유저에게만 노출***
|
||||
/// *동작 : 기본 클릭 시 어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가 ·*
|
||||
/// ***2초 롱프레스 시 옵션으로 전환***
|
||||
///
|
||||
/// 차감 수치가 **어드민 설정값**이라는 것이 명세에 박혀 있다. 화면에 상수로 박지
|
||||
/// 않고 [zapPop] 으로 받는다 — 정책 설계서 §P-08 의 Admin 운영값이다.
|
||||
class ZapButton extends StatefulWidget {
|
||||
const ZapButton({
|
||||
super.key,
|
||||
required this.zapPop,
|
||||
required this.myPop,
|
||||
required this.onZap,
|
||||
this.blockedReason,
|
||||
this.isHeroTime = false,
|
||||
this.onContribute,
|
||||
this.onItem,
|
||||
this.round = false,
|
||||
this.showLabel = true,
|
||||
});
|
||||
|
||||
/// 한 번에 기여할 POP. Admin 운영값.
|
||||
final int zapPop;
|
||||
|
||||
/// 내 활동 POP 잔액. 모자라면 누를 수 없다.
|
||||
final int myPop;
|
||||
|
||||
/// HERO 참여는 개인 POP 차감이 아니라 선물 수령 화면으로 진입한다.
|
||||
final bool isHeroTime;
|
||||
|
||||
/// 전송 중·소속 없음 등 입력을 막는 사유. Eclipse 방어와는 무관하다.
|
||||
final String? blockedReason;
|
||||
|
||||
final VoidCallback onZap;
|
||||
final VoidCallback? onContribute;
|
||||
final VoidCallback? onItem;
|
||||
|
||||
/// HOME 의 원형 Zap — Figma 사본 `252:159354` 「Frame 1984081111」(52 원 · 청록→파랑
|
||||
/// 그라디언트 · 번개 + 「기여」). 옵션은 위에 기여, 아래 줄에 닫기·아이템(`Frame 1984081131`).
|
||||
final bool round;
|
||||
|
||||
/// 원형 기본 상태의 「기여」 라벨. 성운 상태 사본(`344:213778`)은 번개만 있다.
|
||||
final bool showLabel;
|
||||
|
||||
/// 명세의 「2초간 롱프레싱」.
|
||||
static const longPress = Duration(seconds: 2);
|
||||
|
||||
@override
|
||||
State<ZapButton> createState() => _ZapButtonState();
|
||||
}
|
||||
|
||||
class _ZapButtonState extends State<ZapButton> {
|
||||
bool _optionsOpen = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ZapButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isHeroTime != widget.isHeroTime) _optionsOpen = false;
|
||||
}
|
||||
|
||||
bool get _canZap =>
|
||||
widget.blockedReason == null && (widget.isHeroTime || widget.myPop >= widget.zapPop);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: widget.round
|
||||
? (_optionsOpen ? _roundOptions(context) : _roundBasic(context))
|
||||
: (_optionsOpen ? _options(context) : _basic(context)),
|
||||
);
|
||||
|
||||
Widget _roundBasic(BuildContext context) => RawGestureDetector(
|
||||
gestures: {
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(duration: ZapButton.longPress),
|
||||
(recognizer) =>
|
||||
recognizer.onLongPress = () =>
|
||||
setState(() => _optionsOpen = true),
|
||||
),
|
||||
},
|
||||
// Tooltip 은 쓰지 않는다 — 자체 길게 누르기가 2초 옵션 전환을 가로챈다.
|
||||
child: Semantics(
|
||||
label: _canZap
|
||||
? (widget.isHeroTime
|
||||
? 'ZAP+ · HERO TIME'
|
||||
: 'ZAP ${widget.zapPop} POP')
|
||||
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
|
||||
child: Opacity(
|
||||
opacity: _canZap ? 1 : 0.4,
|
||||
child: _RoundZap(
|
||||
icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic),
|
||||
label: widget.isHeroTime ? 'ZAP+' : context.l10n.commonContribute,
|
||||
fill: _zapGradient,
|
||||
glow: true,
|
||||
onTap: _canZap ? widget.onZap : null,
|
||||
showLabel: widget.showLabel,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _roundOptions(BuildContext context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RoundZap(
|
||||
icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic),
|
||||
label: context.l10n.commonContribute,
|
||||
fill: _zapGradient,
|
||||
glow: true,
|
||||
onTap: widget.onContribute,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
// 사본의 왼쪽 「자동」 자리는 비운다(클래스 주석 — 별첨 B 에 없는 기능).
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 52 + AppSpace.s6),
|
||||
_RoundZap(
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
label: context.l10n.commonClose,
|
||||
showLabel: false,
|
||||
ring: true,
|
||||
onTap: () => setState(() => _optionsOpen = false),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s6),
|
||||
_RoundZap(
|
||||
icon: const ImageIcon(_item, size: 20, color: AppColors.onCosmic),
|
||||
label: context.l10n.commonItem,
|
||||
onTap: widget.onItem,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _basic(BuildContext context) => RawGestureDetector(
|
||||
gestures: {
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(duration: ZapButton.longPress),
|
||||
(recognizer) =>
|
||||
recognizer.onLongPress = () =>
|
||||
setState(() => _optionsOpen = true),
|
||||
),
|
||||
},
|
||||
child: FilledButton.icon(
|
||||
onPressed: _canZap ? widget.onZap : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
||||
),
|
||||
icon: const Icon(Icons.bolt),
|
||||
label: Text(
|
||||
_canZap
|
||||
? widget.isHeroTime ? 'ZAP+ · HERO TIME' : 'ZAP ${widget.zapPop} POP'
|
||||
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _options(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceRaised,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.cosmicOutline),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_Option(
|
||||
icon: Icons.volunteer_activism_outlined,
|
||||
label: context.l10n.commonContribute,
|
||||
onTap: widget.onContribute,
|
||||
),
|
||||
_Option(
|
||||
icon: Icons.card_giftcard_outlined,
|
||||
label: context.l10n.commonItem,
|
||||
onTap: widget.onItem,
|
||||
),
|
||||
_Option(
|
||||
icon: Icons.close,
|
||||
label: context.l10n.commonClose,
|
||||
onTap: () => setState(() => _optionsOpen = false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Option extends StatelessWidget {
|
||||
const _Option({required this.icon, required this.label, this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: AppSizing.touchMinApp),
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpace.s2),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: AppColors.onCosmic),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: t.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 원형 Zap 한 칸 — 52 원 · 패딩 8 · 아이콘 20 위 10pt 라벨(42dot Medium).
|
||||
class _RoundZap extends StatelessWidget {
|
||||
const _RoundZap({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.fill,
|
||||
this.glow = false,
|
||||
this.ring = false,
|
||||
this.showLabel = true,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final Gradient? fill;
|
||||
final bool glow;
|
||||
final bool ring;
|
||||
final bool showLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: fill,
|
||||
color: fill == null && !ring ? const Color(0xFF050505) : null,
|
||||
border: ring ? Border.all(color: const Color(0xFF4F98C8)) : null,
|
||||
boxShadow: glow
|
||||
? const [BoxShadow(color: Color(0xFF547BD4), blurRadius: 6)]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: showLabel
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
icon,
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.6,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/planet_fx_scope.dart';
|
||||
import '../../../core/format.dart';
|
||||
import '../galaxy_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// v1.5 §3: 입력 에너지는 즉시, 흡수·기여 결과는 서버 거래 확정 후에만 표시한다.
|
||||
class ZapFeedback extends StatefulWidget {
|
||||
const ZapFeedback({
|
||||
super.key,
|
||||
required this.touchSerial,
|
||||
required this.pending,
|
||||
required this.result,
|
||||
required this.source,
|
||||
required this.target,
|
||||
required this.radius,
|
||||
});
|
||||
|
||||
final int touchSerial;
|
||||
final bool pending;
|
||||
final ZapResult? result;
|
||||
final Offset source;
|
||||
final Offset target;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<ZapFeedback> createState() => _ZapFeedbackState();
|
||||
}
|
||||
|
||||
class _ZapFeedbackState extends State<ZapFeedback>
|
||||
with TickerProviderStateMixin {
|
||||
late final _travel = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 380),
|
||||
);
|
||||
late final _absorb = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
);
|
||||
final _seen = <String>{};
|
||||
bool _showResult = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ZapFeedback old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.touchSerial != old.touchSerial) {
|
||||
_showResult = false;
|
||||
_travel.forward(from: 0);
|
||||
}
|
||||
final result = widget.result;
|
||||
if (result != null && result.txId.isNotEmpty && _seen.add(result.txId)) {
|
||||
_showResult = true;
|
||||
_absorb.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_travel.dispose();
|
||||
_absorb.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduced = MediaQuery.disableAnimationsOf(context);
|
||||
final strongFx = PlanetFxScope.of(context)?.busy ?? false;
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_travel, _absorb]),
|
||||
builder: (context, _) {
|
||||
final result = widget.result;
|
||||
final showResult = _showResult && !widget.pending && _absorb.value < 1;
|
||||
return Stack(
|
||||
children: [
|
||||
if (!reduced && !strongFx)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _ZapPainter(
|
||||
source: widget.source,
|
||||
target: widget.target,
|
||||
radius: widget.radius,
|
||||
travel: _travel.value,
|
||||
travelling: _travel.isAnimating,
|
||||
absorption: showResult ? _absorb.value : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.pending || showResult)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 128,
|
||||
child: Center(
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Text(
|
||||
widget.pending
|
||||
? context.l10n.zapApplying
|
||||
: result?.contributedPop == null
|
||||
? context.l10n.zapDone
|
||||
: context.l10n.zapDonePop(
|
||||
formatPop(result!.contributedPop!),
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ZapPainter extends CustomPainter {
|
||||
const _ZapPainter({
|
||||
required this.source,
|
||||
required this.target,
|
||||
required this.radius,
|
||||
required this.travel,
|
||||
required this.travelling,
|
||||
required this.absorption,
|
||||
});
|
||||
final Offset source;
|
||||
final Offset target;
|
||||
final double radius;
|
||||
final double travel;
|
||||
final bool travelling;
|
||||
final double? absorption;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const color = AppColors.cosmicAccent;
|
||||
if (travelling) {
|
||||
final t = Curves.easeOutCubic.transform(travel);
|
||||
// 짧은 직선 이동, 잔상은 끝점 근처 3개로 제한한다.
|
||||
for (var i = 2; i >= 0; i--) {
|
||||
final p = Offset.lerp(source, target, (t - i * 0.04).clamp(0.0, 1.0))!;
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
5.0 - i,
|
||||
Paint()
|
||||
..color = color.withValues(
|
||||
alpha: (1 - i * 0.25) * (1 - travel * 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
final a = absorption;
|
||||
if (a == null || a > 0.5) return;
|
||||
final t = a * 2;
|
||||
canvas.drawCircle(
|
||||
target,
|
||||
radius * (1 + t * 0.5),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3 * (1 - t) + 0.5
|
||||
..color = color.withValues(alpha: 0.75 * (1 - t)),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
target,
|
||||
radius * 1.5,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
color.withValues(alpha: 0.3 * (1 - t)),
|
||||
color.withValues(alpha: 0),
|
||||
],
|
||||
).createShader(Rect.fromCircle(center: target, radius: radius * 1.5)),
|
||||
);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
final angle = i * math.pi / 4;
|
||||
final p =
|
||||
target +
|
||||
Offset(math.cos(angle), math.sin(angle)) * radius * (1.5 - t * 0.5);
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
2 * (1 - t),
|
||||
Paint()..color = color.withValues(alpha: 1 - t),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ZapPainter old) =>
|
||||
old.source != source ||
|
||||
old.target != target ||
|
||||
old.radius != radius ||
|
||||
old.travel != travel ||
|
||||
old.travelling != travelling ||
|
||||
old.absorption != absorption;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/data/backend.dart';
|
||||
import 'galaxy_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// HOME과 Galaxy의 동일한 기여 거래: 연타 합산, 확정 결과, 동일 키 재확인.
|
||||
class ZapController extends ChangeNotifier {
|
||||
ZapController(this.repository, {required this.onFailure});
|
||||
final GalaxyRepository repository;
|
||||
final ValueChanged<String> onFailure;
|
||||
int? balance;
|
||||
int pending = 0;
|
||||
bool sending = false;
|
||||
int touchSerial = 0;
|
||||
ZapResult? result;
|
||||
Timer? _burst;
|
||||
bool _disposed = false;
|
||||
({String fandomId, int count, String key})? _batch;
|
||||
bool get needsRetry => _batch != null && !sending;
|
||||
|
||||
void tap(String fandomId) {
|
||||
if (_disposed || sending || _batch != null || fandomId.isEmpty) return;
|
||||
if (pending == 0) HapticFeedback.lightImpact();
|
||||
pending++;
|
||||
touchSerial++;
|
||||
notifyListeners();
|
||||
_burst ??= Timer(const Duration(milliseconds: 450), () => _flush(fandomId));
|
||||
}
|
||||
|
||||
void retry() {
|
||||
if (_batch case final batch?) _flush(batch.fandomId);
|
||||
}
|
||||
|
||||
Future<void> _flush(String fandomId) async {
|
||||
if (_disposed || sending) return;
|
||||
final batch =
|
||||
_batch ??
|
||||
(
|
||||
fandomId: fandomId,
|
||||
count: pending,
|
||||
key: 'z${DateTime.now().microsecondsSinceEpoch}',
|
||||
);
|
||||
if (batch.count == 0) return;
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
_batch = batch;
|
||||
sending = true;
|
||||
pending = 0;
|
||||
notifyListeners();
|
||||
try {
|
||||
final receipt = await repository.zap(
|
||||
fandomId: batch.fandomId,
|
||||
count: batch.count,
|
||||
clientSeq: batch.key,
|
||||
);
|
||||
if (_disposed) return;
|
||||
if (receipt.txId.isEmpty) throw StateError('Missing ZAP receipt');
|
||||
if (receipt.balanceAfter >= 0) balance = receipt.balanceAfter;
|
||||
result = receipt;
|
||||
_batch = null;
|
||||
} catch (error) {
|
||||
if (_disposed) return;
|
||||
onFailure(switch (error) {
|
||||
BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop,
|
||||
BackendException(code: 'E-POP-02') => appL10n.zapFailLedger,
|
||||
BackendException(code: 'E-AUTH-03') => appL10n.authLoginRequired,
|
||||
_ => appL10n.zapFailRetry,
|
||||
});
|
||||
} finally {
|
||||
if (!_disposed) {
|
||||
sending = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_burst?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user