라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응
This commit is contained in:
@@ -85,10 +85,21 @@ class GalaxySnapshot {
|
||||
this.event,
|
||||
this.joined = true,
|
||||
this.memberships = const [],
|
||||
this.joinedIds = const {},
|
||||
});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
|
||||
/// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 —
|
||||
/// 갤럭시에서 고른 행성이 여기에 있으면 그 행성에 Zap 한다.
|
||||
final Set<String> joinedIds;
|
||||
|
||||
/// Zap 이 향할 행성 — 고른 행성이 가입 팬덤이면 그 행성, 아니면 대표 행성(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」).
|
||||
PlanetNode? zapTargetFor(PlanetNode? selected) =>
|
||||
selected != null && (selected.isMine || joinedIds.contains(selected.id))
|
||||
? selected
|
||||
: mine;
|
||||
|
||||
/// 가입하거나 만든 행성/성운 — 최신 가입순(명세 §2.58 「행성/성운 모아보기」).
|
||||
/// [PlanetNode.isMine] 이 대표 팬덤(C-15)이다.
|
||||
final List<PlanetNode> memberships;
|
||||
@@ -138,6 +149,7 @@ class GalaxySnapshot {
|
||||
event: event,
|
||||
joined: joined,
|
||||
memberships: memberships,
|
||||
joinedIds: joinedIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -181,6 +193,9 @@ abstract interface class GalaxyRepository {
|
||||
/// 대표 팬덤 변경 — `fn-primary-fandom-set`(원장 C-15). 30일 대기 중이면
|
||||
/// E-FDM-04 로 거부된다.
|
||||
Future<void> setPrimary(String fandomId);
|
||||
|
||||
/// 행성 이름 검색 — Figma 「Galaxy_검색」(`353:217832`). 맵에 올라온 행성만이 아니라 **전체** 행성에서 찾는다.
|
||||
Future<List<PlanetNode>> search(String query);
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
@@ -192,6 +207,15 @@ class SampleGalaxyRepository implements GalaxyRepository {
|
||||
@override
|
||||
Future<void> setPrimary(String fandomId) async {}
|
||||
|
||||
@override
|
||||
Future<List<PlanetNode>> search(String query) async {
|
||||
final q = query.trim().toLowerCase();
|
||||
return [
|
||||
for (final p in (await load()).planets)
|
||||
if (q.isNotEmpty && p.name.toLowerCase().contains(q)) p,
|
||||
];
|
||||
}
|
||||
|
||||
/// 표본은 원장이 없으므로 잔액을 돌려주지 않는다. 화면은 -1 을 「모름」으로 읽는다.
|
||||
@override
|
||||
Future<ZapResult> zap({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart'
|
||||
show QueryDocumentSnapshot;
|
||||
show FieldPath, QueryDocumentSnapshot;
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../planet/planet_data.dart' show rankWithTies;
|
||||
@@ -102,9 +102,42 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
|
||||
joined: myFandomId.isNotEmpty,
|
||||
memberships: [for (final id in membershipIds) ?byId[id]],
|
||||
joinedIds: {
|
||||
...?(user['fandomIds'] as List?)?.whereType<String>(),
|
||||
if (myFandomId.isNotEmpty) myFandomId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 전체 행성 이름 접두 검색 — 이름 색인 `fandomNames/{소문자 이름}`(서버가 성운 생성 때 쓴다).
|
||||
/// 맵 상위 [visibleCount] 개 안에서만 거르던 것을 고쳤다(Figma `353:217832` 「행성 검색으로 대상 행성 탐색」).
|
||||
/// ponytail: 접두 일치만 — 중간 글자 일치가 필요하면 검색 색인(n-gram)을 서버에 둔다.
|
||||
@override
|
||||
Future<List<PlanetNode>> search(String query) async {
|
||||
final q = query.trim().toLowerCase();
|
||||
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 myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
final names = await db
|
||||
.collection('fandomNames')
|
||||
.where(FieldPath.documentId, isGreaterThanOrEqualTo: q)
|
||||
.where(FieldPath.documentId, isLessThan: '$q\uf8ff')
|
||||
.limit(20)
|
||||
.get();
|
||||
final ids = {
|
||||
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()]);
|
||||
return [
|
||||
for (final doc in docs)
|
||||
if (doc.data() case final data?)
|
||||
_node(doc.id, data, 0, myFandomId, ranked: false),
|
||||
];
|
||||
}
|
||||
|
||||
PlanetNode _node(
|
||||
String id,
|
||||
Map<String, dynamic> data,
|
||||
|
||||
@@ -601,7 +601,7 @@ class _Map extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
if (data.mine case final mine?)
|
||||
if (data.zapTargetFor(selected) case final mine?)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedBuilder(
|
||||
@@ -610,6 +610,7 @@ class _Map extends StatelessWidget {
|
||||
touchSerial: touchSerial,
|
||||
pending: pending > 0 || sending,
|
||||
result: zapResult,
|
||||
fandomName: mine.name,
|
||||
source: Offset(
|
||||
viewport.width / 2,
|
||||
viewport.height -
|
||||
@@ -653,8 +654,8 @@ class _Map extends StatelessWidget {
|
||||
// 하단의 GALAXY 라벨·돋보기(40) 위에 놓는다.
|
||||
bottom: AppSpace.s4 + 40 + AppSpace.s3,
|
||||
child: LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey(('zap-mode', data.mine?.id)),
|
||||
create: () => watchPlanet(data.mine?.id ?? ''),
|
||||
key: ValueKey(('zap-mode', data.zapTargetFor(selected)?.id)),
|
||||
create: () => watchPlanet(data.zapTargetFor(selected)?.id ?? ''),
|
||||
builder: (context, state) {
|
||||
final hero =
|
||||
state?.isHeroTime == true &&
|
||||
@@ -679,7 +680,8 @@ class _Map extends StatelessWidget {
|
||||
? context.l10n.zapNoPlanet
|
||||
: null,
|
||||
onZap: () {
|
||||
if (data.mine case final mine?) {
|
||||
// 고른 행성이 가입 팬덤이면 그 행성에, 아니면 대표 행성에(10/3 #10).
|
||||
if (data.zapTargetFor(selected) case final mine?) {
|
||||
if (hero) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/play_banner.dart';
|
||||
@@ -32,20 +35,62 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot;
|
||||
final _controller = TextEditingController();
|
||||
|
||||
/// 최근 검색어. 기기 안에만 두는 값이라 서버가 붙어도 그대로다.
|
||||
/// 최근 검색어 — 기기에 남긴다(재진입해도 유지). 명세 `523:18429` 「최신순 · 중복 제거」.
|
||||
final List<String> _recent = [];
|
||||
static const _recentKey = 'galaxyRecentSearches';
|
||||
|
||||
String _query = '';
|
||||
|
||||
/// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다.
|
||||
Future<List<PlanetNode>>? _hits;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snapshot = widget.repository.load();
|
||||
_controller.addListener(() => setState(() => _query = _controller.text));
|
||||
_controller.addListener(_onChanged);
|
||||
_loadRecent();
|
||||
}
|
||||
|
||||
Future<void> _loadRecent() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey);
|
||||
if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved));
|
||||
} catch (_) {
|
||||
// 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveRecent() async {
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setStringList(_recentKey, _recent);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
if (_controller.text == _query) return;
|
||||
setState(() => _query = _controller.text);
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
final q = _query.trim();
|
||||
setState(() {
|
||||
_hits = q.isEmpty ? null : _search(q);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버 결과에 맵 스냅샷의 순위를 입힌다 — 맵 상위에 있는 행성은 같은 순위로 보인다.
|
||||
Future<List<PlanetNode>> _search(String q) async {
|
||||
final hits = await widget.repository.search(q);
|
||||
final known = {for (final p in (await _snapshot).planets) p.id: p};
|
||||
return [for (final h in hits) known[h.id] ?? h];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -59,6 +104,7 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
..insert(0, term);
|
||||
if (_recent.length > 10) _recent.removeLast();
|
||||
});
|
||||
_saveRecent();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -135,34 +181,41 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
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),
|
||||
onClear: () {
|
||||
setState(_recent.clear);
|
||||
_saveRecent();
|
||||
},
|
||||
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],
|
||||
onTap: widget.onOpen == null
|
||||
? null
|
||||
: () => widget.onOpen!(hits[i]),
|
||||
),
|
||||
return FutureBuilder<List<PlanetNode>>(
|
||||
future: _hits,
|
||||
builder: (context, result) {
|
||||
if (_hits == null ||
|
||||
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]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -18,8 +18,12 @@ class ZapFeedback extends StatefulWidget {
|
||||
required this.source,
|
||||
required this.target,
|
||||
required this.radius,
|
||||
this.fandomName,
|
||||
});
|
||||
|
||||
/// 기여가 반영된 행성 이름 — 9/21 회신 6절 「N POP 이 [팬덤] 성장에 반영되었습니다」.
|
||||
final String? fandomName;
|
||||
|
||||
final int touchSerial;
|
||||
final bool pending;
|
||||
final ZapResult? result;
|
||||
@@ -112,6 +116,11 @@ class _ZapFeedbackState extends State<ZapFeedback>
|
||||
? context.l10n.zapApplying
|
||||
: result?.contributedPop == null
|
||||
? context.l10n.zapDone
|
||||
: (widget.fandomName ?? '').isNotEmpty
|
||||
? context.l10n.zapReflected(
|
||||
formatPop(result!.contributedPop!),
|
||||
widget.fandomName!,
|
||||
)
|
||||
: context.l10n.zapDonePop(
|
||||
formatPop(result!.contributedPop!),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user