Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림
This commit is contained in:
@@ -90,8 +90,13 @@ class GalaxySnapshot {
|
||||
this.joined = true,
|
||||
this.memberships = const [],
|
||||
this.joinedIds = const {},
|
||||
this.nebulaCreatedToday = false,
|
||||
});
|
||||
|
||||
/// 오늘(KST) 이미 성운을 만들었는가 — Figma `540:19592` 「일1회 생성 가능(이후 비활성화) ·
|
||||
/// 횟수 초과 시 초과 안내 모달」. 서버 `fn-nebula-create` 의 일 1회 판정(E-FDM-02)과 같은 문서를 본다.
|
||||
final bool nebulaCreatedToday;
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
|
||||
/// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 —
|
||||
@@ -155,6 +160,7 @@ class GalaxySnapshot {
|
||||
joined: joined,
|
||||
memberships: memberships,
|
||||
joinedIds: joinedIds,
|
||||
nebulaCreatedToday: nebulaCreatedToday,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ class GalaxyHomeScreen extends StatefulWidget {
|
||||
class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot = widget.repository.load();
|
||||
|
||||
/// Figma `540:19580` 그리드 변경 영역 — 디폴트 격자, 누르면 격자 ↔ 리스트(`354:221533`).
|
||||
bool _list = false;
|
||||
|
||||
/// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로.
|
||||
Future<void> _choose(PlanetNode p) async {
|
||||
if (p.isMine) return _openMap();
|
||||
@@ -104,7 +107,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
/// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다.
|
||||
Future<void> _createNebula() async {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final myPop = (await _snapshot).myPop;
|
||||
final snapshot = await _snapshot;
|
||||
// Figma `540:19592` · `543:19609` — 일 1회. 오늘 이미 만들었으면 화면을 열지 않고 초과 안내 모달.
|
||||
if (snapshot.nebulaCreatedToday) {
|
||||
if (mounted)
|
||||
await showAlertDialog(context, context.l10n.nebulaAlreadyToday);
|
||||
return;
|
||||
}
|
||||
final myPop = snapshot.myPop;
|
||||
final fandomId = await navigator.push(
|
||||
MaterialPageRoute<String>(
|
||||
builder: (_) =>
|
||||
@@ -155,16 +165,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// 시안은 내 행성 셋을 보여 준다 — 가입하거나 만든 행성/성운, 최신 가입순(§2.58).
|
||||
// 「선택」 알약은 대표 팬덤(C-15)이다.
|
||||
final mine = data.memberships.take(3).toList();
|
||||
// 가입하거나 만든 행성/성운 전부, 최신 가입순(Figma `540:19586`). 「선택」 알약은 대표 팬덤(C-15).
|
||||
final mine = data.memberships;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s9),
|
||||
Column(
|
||||
children: [
|
||||
// 상단 — 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42
|
||||
// 상단 — 상단 고정 노출(Figma `540:19574`). 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
@@ -191,6 +199,8 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxySearchScreen(
|
||||
repository: widget.repository,
|
||||
onJoin: (p) =>
|
||||
widget.planetRepository(p.id).join(),
|
||||
onOpen: (p) =>
|
||||
Navigator.of(
|
||||
context,
|
||||
@@ -226,73 +236,77 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 이클립스 인앱 경보 — 별첨 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(
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s9),
|
||||
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: GalaxyHomeScreen.cardImageOf(p, i),
|
||||
selected: p.isMine,
|
||||
onTap: () => _choose(p),
|
||||
// 이클립스 인앱 경보 — 별첨 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,
|
||||
),
|
||||
],
|
||||
// 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다.
|
||||
for (var i = mine.length; i < 3; i++) ...[
|
||||
if (i > 0) const SizedBox(width: AppSpace.s2),
|
||||
const Expanded(child: _EmptySlot()),
|
||||
],
|
||||
// 「내 행성」 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(),
|
||||
// 격자 ↔ 리스트(Figma `540:19580`). 궤도 맵은 선택된 카드를 눌러 연다.
|
||||
InkResponse(
|
||||
onTap: () => setState(() => _list = !_list),
|
||||
radius: 20,
|
||||
child: Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
label: _list
|
||||
? context.l10n.galaxyGridView
|
||||
: context.l10n.galaxyListView,
|
||||
child: _list
|
||||
? const _GridIcon()
|
||||
: Image.asset(
|
||||
'assets/figma/icon-list.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: const Color(0xFF777777),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _list
|
||||
? _PlanetList(
|
||||
planets: mine,
|
||||
imageOf: GalaxyHomeScreen.cardImageOf,
|
||||
onTap: _choose,
|
||||
)
|
||||
: _PlanetGrid(planets: mine, onTap: _choose),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -303,7 +317,13 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: Center(child: _NebulaPill(onTap: _createNebula)),
|
||||
child: Center(
|
||||
child: _NebulaPill(
|
||||
onTap: _createNebula,
|
||||
// 일 1회 생성 뒤 비활성화(Figma `540:19592`). 눌러도 초과 안내 모달만 뜬다.
|
||||
disabled: data.nebulaCreatedToday,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -426,44 +446,221 @@ class _EmptySlot extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _NebulaPill extends StatelessWidget {
|
||||
const _NebulaPill({required this.onTap});
|
||||
const _NebulaPill({required this.onTap, this.disabled = false});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool disabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
return Opacity(
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
child: Material(
|
||||
color: const Color(0xFF19191C),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 격자 — 세 칸씩 줄을 채운다. 행성이 셋보다 적으면 빈 칸으로 한 줄을 맞춘다(시안 세 칸).
|
||||
class _PlanetGrid extends StatelessWidget {
|
||||
const _PlanetGrid({required this.planets, required this.onTap});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
final ValueChanged<PlanetNode> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cells = planets.length < 3 ? 3 : (planets.length + 2) ~/ 3 * 3;
|
||||
return Column(
|
||||
children: [
|
||||
for (var row = 0; row < cells; row += 3) ...[
|
||||
if (row > 0) const SizedBox(height: AppSpace.s2),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = row; i < row + 3; i++) ...[
|
||||
if (i > row) const SizedBox(width: AppSpace.s2),
|
||||
Expanded(
|
||||
child: i < planets.length
|
||||
? _PlanetCard(
|
||||
planet: planets[i],
|
||||
image: GalaxyHomeScreen.cardImageOf(planets[i], i),
|
||||
selected: planets[i].isMine,
|
||||
onTap: () => onTap(planets[i]),
|
||||
)
|
||||
: const _EmptySlot(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 리스트 그리드 — 시안 `354:221533`: 80×79 #2c2c2c r8 칸(그림 64) · 간격 15 · 이름 16/600,
|
||||
/// 줄 사이 1px #2c2c2c. 선택된 행성은 칸 위에 「선택」 알약.
|
||||
class _PlanetList extends StatelessWidget {
|
||||
const _PlanetList({
|
||||
required this.planets,
|
||||
required this.imageOf,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
final String Function(PlanetNode, [int]) imageOf;
|
||||
final ValueChanged<PlanetNode> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
for (final (i, p) in planets.indexed) ...[
|
||||
if (i > 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Divider(height: 1, thickness: 1, color: Color(0xFF2C2C2C)),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => onTap(p),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
height: 79,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: PlanetArt(
|
||||
fallback: imageOf(p, i),
|
||||
planetType: p.stage == 0 ? null : p.planetType,
|
||||
colorKey: p.colorKey,
|
||||
semanticLabel: p.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (p.isMine)
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 22 / 16,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 리스트 상태의 「격자로」 아이콘 — 시안 9×9 r1 #777777 네 칸(20×20).
|
||||
class _GridIcon extends StatelessWidget {
|
||||
const _GridIcon();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget sq() => Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF777777),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
return SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [sq(), sq()],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [sq(), sq()],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -26,13 +26,20 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
final user = userSnap.data() ?? const <String, dynamic>{};
|
||||
// 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다.
|
||||
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
// 「가입 또는 생성한 행성/성운」 — 최신 가입순. 시안 그리드가 세 칸이라 셋만 읽는다.
|
||||
// ponytail: 모아보기 리스트 그리드(명세 §2.58-2)를 붙이면 limit 과 페이지를 연다.
|
||||
// 「가입 또는 생성한 행성/성운」 — 최신 가입순 전부(Figma `540:19586`). 세 칸은 한 줄의 칸 수다.
|
||||
final membershipsF = db
|
||||
.collection('users/$uid/memberships')
|
||||
.orderBy('joinedAt', descending: true)
|
||||
.limit(3)
|
||||
.get();
|
||||
// 오늘(KST) 성운 생성 기록 — 서버 `dayKey`(UTC+9)와 같은 키.
|
||||
final kst = DateTime.now().toUtc().add(const Duration(hours: 9));
|
||||
final todayKey =
|
||||
'${kst.year.toString().padLeft(4, '0')}-${kst.month.toString().padLeft(2, '0')}-${kst.day.toString().padLeft(2, '0')}';
|
||||
// 규칙 배포 전이면 읽기가 거부된다 — 그때는 버튼을 막지 않고 서버 판정(E-FDM-02)에 맡긴다.
|
||||
final createdTodayF = db
|
||||
.doc('users/$uid/nebulaCreates/$todayKey')
|
||||
.get()
|
||||
.then((d) => d.exists, onError: (_) => false);
|
||||
|
||||
final fandomsF = db
|
||||
.collection('fandoms')
|
||||
@@ -102,6 +109,7 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
|
||||
joined: myFandomId.isNotEmpty,
|
||||
memberships: [for (final id in membershipIds) ?byId[id]],
|
||||
nebulaCreatedToday: await createdTodayF,
|
||||
joinedIds: {
|
||||
...?(user['fandomIds'] as List?)?.whereType<String>(),
|
||||
if (myFandomId.isNotEmpty) myFandomId,
|
||||
@@ -118,7 +126,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
if (q.isEmpty) return const [];
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
final user = (await db.doc('users/$uid').get()).data() ?? const <String, dynamic>{};
|
||||
final user =
|
||||
(await db.doc('users/$uid').get()).data() ?? const <String, dynamic>{};
|
||||
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
final names = await db
|
||||
.collection('fandomNames')
|
||||
@@ -130,7 +139,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
for (final d in names.docs)
|
||||
if (d.data()['fandomId'] case final String id when id.isNotEmpty) id,
|
||||
};
|
||||
final docs = await Future.wait([for (final id in ids) db.doc('fandoms/$id').get()]);
|
||||
final docs = await Future.wait([
|
||||
for (final id in ids) db.doc('fandoms/$id').get(),
|
||||
]);
|
||||
return [
|
||||
for (final doc in docs)
|
||||
if (doc.data() case final data?)
|
||||
@@ -152,7 +163,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
_ => null,
|
||||
},
|
||||
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
|
||||
planetType: data['planetType'] is String ? data['planetType'] as String : null,
|
||||
planetType: data['planetType'] is String
|
||||
? data['planetType'] as String
|
||||
: null,
|
||||
name: data.strOr('name', id),
|
||||
rank: rank,
|
||||
fpi: data.intOr('fpiScore'),
|
||||
|
||||
@@ -573,11 +573,9 @@ class _Map extends StatelessWidget {
|
||||
state: e,
|
||||
onTap: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => EclipseDefendScreen(
|
||||
repository: eclipseRepository,
|
||||
fandomId: e.fandomId,
|
||||
),
|
||||
eclipseDefendRoute(
|
||||
repository: eclipseRepository,
|
||||
fandomId: e.fandomId,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/ui/planet_25d.dart' show PlanetArt;
|
||||
import '../../core/ui/play_banner.dart';
|
||||
import '../planet/widgets/fandom_size_badge.dart';
|
||||
import 'galaxy_data.dart';
|
||||
import '../../core/format.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -20,10 +23,14 @@ class GalaxySearchScreen extends StatefulWidget {
|
||||
super.key,
|
||||
this.repository = const SampleGalaxyRepository(),
|
||||
this.onOpen,
|
||||
this.onJoin,
|
||||
});
|
||||
|
||||
final GalaxyRepository repository;
|
||||
|
||||
/// 결과 줄의 「가입」 — Figma `543:19694` 「가입 처리 후 가입중 버튼스타일로 변경」.
|
||||
final Future<void> Function(PlanetNode planet)? onJoin;
|
||||
|
||||
/// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다.
|
||||
final void Function(PlanetNode planet)? onOpen;
|
||||
|
||||
@@ -41,6 +48,17 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
|
||||
String _query = '';
|
||||
|
||||
/// Figma `543:19672` 탭 — 디폴트 행성(false) · 성운(true).
|
||||
bool _nebulaTab = false;
|
||||
|
||||
/// Figma `543:19683` 필터 — 디폴트 전체. 행성: 전체 · 가입자 수 · FPI / 성운: 전체 · 가입자 수.
|
||||
/// 성운의 「기여율」 필터 · 칩(`543:19704` · `543:19714`)은 기여율의 정의가 자료에 없어 해석 확인 후 붙인다.
|
||||
_Sort _sort = _Sort.all;
|
||||
|
||||
/// 이 화면에서 가입한 행성 — 서버 확인 뒤 「가입중」으로 바꾼다.
|
||||
final _joined = <String>{};
|
||||
final _joining = <String>{};
|
||||
|
||||
/// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다.
|
||||
Future<List<PlanetNode>>? _hits;
|
||||
Timer? _debounce;
|
||||
@@ -55,8 +73,15 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
|
||||
Future<void> _loadRecent() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey);
|
||||
if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved));
|
||||
final saved = (await SharedPreferences.getInstance()).getStringList(
|
||||
_recentKey,
|
||||
);
|
||||
if (mounted && saved != null)
|
||||
setState(
|
||||
() => _recent
|
||||
..clear()
|
||||
..addAll(saved),
|
||||
);
|
||||
} catch (_) {
|
||||
// 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다.
|
||||
}
|
||||
@@ -64,7 +89,10 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
|
||||
Future<void> _saveRecent() async {
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setStringList(_recentKey, _recent);
|
||||
await (await SharedPreferences.getInstance()).setStringList(
|
||||
_recentKey,
|
||||
_recent,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -95,6 +123,24 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _join(PlanetNode p) async {
|
||||
final join = widget.onJoin;
|
||||
if (join == null || _joining.contains(p.id)) return;
|
||||
setState(() => _joining.add(p.id));
|
||||
try {
|
||||
await join(p);
|
||||
if (mounted) setState(() => _joined.add(p.id));
|
||||
} on BackendException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _joining.remove(p.id));
|
||||
}
|
||||
}
|
||||
|
||||
void _submit(String q) {
|
||||
final term = q.trim();
|
||||
if (term.isEmpty) return;
|
||||
@@ -201,19 +247,64 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
result.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final hits = result.data ?? const <PlanetNode>[];
|
||||
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],
|
||||
onTap: widget.onOpen == null
|
||||
? null
|
||||
: () => widget.onOpen!(hits[i]),
|
||||
),
|
||||
final all = result.data ?? const <PlanetNode>[];
|
||||
final joinedIds = {
|
||||
...(snap.data?.joinedIds ?? const <String>{}),
|
||||
..._joined,
|
||||
};
|
||||
final hits = [
|
||||
for (final p in all)
|
||||
if ((p.stage == 0) == _nebulaTab) p,
|
||||
];
|
||||
switch (_sort) {
|
||||
case _Sort.all:
|
||||
break;
|
||||
case _Sort.members:
|
||||
hits.sort((a, b) => b.members.compareTo(a.members));
|
||||
case _Sort.fpi:
|
||||
hits.sort((a, b) => b.fpi.compareTo(a.fpi));
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_Tabs(
|
||||
nebula: _nebulaTab,
|
||||
onPick: (v) => setState(() {
|
||||
_nebulaTab = v;
|
||||
if (v && _sort == _Sort.fpi) _sort = _Sort.all;
|
||||
}),
|
||||
),
|
||||
_Filters(
|
||||
nebula: _nebulaTab,
|
||||
sort: _sort,
|
||||
onPick: (v) => setState(() => _sort = v),
|
||||
),
|
||||
Expanded(
|
||||
child: hits.isEmpty
|
||||
? const _NoHit()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
itemCount: hits.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: 24),
|
||||
itemBuilder: (context, i) {
|
||||
final p = hits[i];
|
||||
final joined =
|
||||
p.isMine || joinedIds.contains(p.id);
|
||||
return _Hit(
|
||||
planet: p,
|
||||
joined: joined,
|
||||
busy: _joining.contains(p.id),
|
||||
onJoin: joined || widget.onJoin == null
|
||||
? null
|
||||
: () => _join(p),
|
||||
onTap: widget.onOpen == null
|
||||
? null
|
||||
: () => widget.onOpen!(p),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -290,13 +381,19 @@ class _Recent extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: AppSpace.s2,
|
||||
runSpacing: AppSpace.s2,
|
||||
children: [
|
||||
for (final term in terms)
|
||||
ActionChip(label: Text(term), onPressed: () => onPick(term)),
|
||||
],
|
||||
// Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10.
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2),
|
||||
child: ClipRect(
|
||||
child: Wrap(
|
||||
spacing: AppSpace.s2,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final term in terms)
|
||||
ActionChip(label: Text(term), onPressed: () => onPick(term)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -318,46 +415,205 @@ class _NoHit extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
enum _Sort { all, members, fpi }
|
||||
|
||||
/// 탭 — 시안 `353:218091` 375×44 · 칸 171 · 16/500(선택 #4bc2ae 밑줄).
|
||||
class _Tabs extends StatelessWidget {
|
||||
const _Tabs({required this.nebula, required this.onPick});
|
||||
|
||||
final bool nebula;
|
||||
final ValueChanged<bool> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (v, label) in [
|
||||
(false, context.l10n.galaxyTabPlanet),
|
||||
(true, context.l10n.galaxyTabNebula),
|
||||
])
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => onPick(v),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: v == nebula
|
||||
? AppColors.cosmicTeal
|
||||
: const Color(0xFF1A1A1A),
|
||||
width: v == nebula ? 2 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: _t(
|
||||
16,
|
||||
v == nebula
|
||||
? AppColors.cosmicTeal
|
||||
: const Color(0xFFBBBBBB),
|
||||
FontWeight.w500,
|
||||
1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 필터 칩 — 시안 32 높이 r8 · 선택 #ffffff(글자 검정) · 비선택 #1a1a1a · 12/500.
|
||||
class _Filters extends StatelessWidget {
|
||||
const _Filters({
|
||||
required this.nebula,
|
||||
required this.sort,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
final bool nebula;
|
||||
final _Sort sort;
|
||||
final ValueChanged<_Sort> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
for (final (v, label) in [
|
||||
(_Sort.all, context.l10n.commonAll),
|
||||
(_Sort.members, context.l10n.galaxySortMembers),
|
||||
if (!nebula) (_Sort.fpi, 'FPI'),
|
||||
]) ...[
|
||||
GestureDetector(
|
||||
onTap: () => onPick(v),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: v == sort ? Colors.white : const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: _t(
|
||||
12,
|
||||
v == sort ? Colors.black : AppColors.onCosmic,
|
||||
FontWeight.w500,
|
||||
20 / 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 결과 한 줄 — 시안 `353:218091`: 그림 70 · 간격 15 · 이름 16/600 · 규모 칩 · 사람 14 + 수 12/500 ·
|
||||
/// 「FPI : n」 12/500, 오른쪽 「가입」(#ff7ddd) / 「가입중」(#1a1a1a) 알약 30 높이.
|
||||
class _Hit extends StatelessWidget {
|
||||
const _Hit({required this.planet, this.onTap});
|
||||
const _Hit({
|
||||
required this.planet,
|
||||
required this.joined,
|
||||
this.busy = false,
|
||||
this.onJoin,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final PlanetNode planet;
|
||||
final bool joined;
|
||||
final bool busy;
|
||||
final VoidCallback? onJoin;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final nebula = planet.stage == 0;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
planet.rankLabel,
|
||||
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 70,
|
||||
child: PlanetArt(
|
||||
fallback: nebula
|
||||
? 'assets/figma/nebula-card.png'
|
||||
: 'assets/figma/planet-detail-hero.png',
|
||||
planetType: nebula ? null : planet.planetType,
|
||||
colorKey: planet.colorKey,
|
||||
semanticLabel: planet.name,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
planet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _t(16, AppColors.onCosmic, FontWeight.w600, 1),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if (!nebula) ...[
|
||||
FandomSizeBadge(members: planet.members),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Icon(
|
||||
Icons.person,
|
||||
size: 14,
|
||||
color: Color(0xFF777777),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
formatPop(planet.members),
|
||||
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
|
||||
),
|
||||
if (!nebula) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'FPI : ${formatPop(planet.fpi)}',
|
||||
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: busy ? null : onJoin,
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: joined
|
||||
? const Color(0xFF1A1A1A)
|
||||
: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined ? context.l10n.planetJoined : context.l10n.galaxyJoin,
|
||||
style: _t(14, AppColors.onCosmic, FontWeight.w500, 1),
|
||||
),
|
||||
),
|
||||
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user