라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응

This commit is contained in:
Sn-Kinos
2026-10-05 22:38:42 +09:00
parent 000c058097
commit 4bb92c2ee7
44 changed files with 727 additions and 78 deletions
@@ -11,9 +11,29 @@ class PlanetVisualState {
this.heroNicknames = const [],
this.heroClaimCount,
this.colorKey,
this.evolutionImminent = false,
});
final int? stage;
/// 다음 Stage 진화 5% 전 — Figma `2024:20758` 「다음 단계 진화 수치 도달 5%전 : 행성에 표면 아우라 발생」.
/// 서버가 적은 레벨업 진행도(`levelPop` · `sfpi`)와 다음 Stage 진입 누계(`nextStagePop` · `nextStageSfpi`)로
/// 판정한다. 두 조건 동시 충족이 진화이므로 **낮은 쪽**이 95% 이상일 때다.
final bool evolutionImminent;
static bool imminentFrom(Map<String, dynamic> data) {
double? ratio(String have, String need) {
final h = data[have];
final n = data[need];
return h is num && n is num && n > 0 ? h / n : null;
}
final pop = ratio('levelPop', 'nextStagePop');
final sfpi = ratio('sfpi', 'nextStageSfpi');
if (pop == null || sfpi == null) return false;
final low = pop < sfpi ? pop : sfpi;
return low >= 0.95 && low < 1;
}
/// 생성 때 고른 행성 컬러 키(C-50 · `fn-planet-create`). 성운이면 null 이다.
final String? colorKey;
final int? level;
@@ -42,6 +62,7 @@ class PlanetVisualState {
int n when n >= 0 => n,
_ => null,
},
evolutionImminent: imminentFrom(data),
);
}
+1
View File
@@ -165,6 +165,7 @@ class Repositories {
colorKey: current.colorKey,
isHibernating: current.isHibernating,
level: current.level,
evolutionImminent: current.evolutionImminent,
isEclipsed: isEclipsed,
isHeroTime: isHeroTime,
heroEventIds: heroEventIds,
+1
View File
@@ -42,6 +42,7 @@ class LivePlanet25D extends StatelessWidget {
tint: planetColorOf(state?.colorKey),
stage: state == null ? stage : state.stage,
level: state?.level,
evolutionImminent: state?.evolutionImminent ?? false,
isEclipsed: state?.isEclipsed ?? false,
isHibernating: state?.isHibernating ?? false,
isHeroTime: state?.isHeroTime ?? false,
+6 -1
View File
@@ -70,6 +70,7 @@ class Planet25D extends StatefulWidget {
this.tint,
this.stage,
this.level,
this.evolutionImminent = false,
this.isEclipsed = false,
this.isHibernating = false,
this.isHeroTime = false,
@@ -79,6 +80,9 @@ class Planet25D extends StatefulWidget {
this.animate = true,
});
/// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]).
final bool evolutionImminent;
/// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`).
final VoidCallback? onOpenHistory;
final String image;
@@ -281,7 +285,8 @@ class _Planet25DState extends State<Planet25D>
),
),
),
if (stage == 6)
// Stage6 지속 아우라 · 진화 5% 전 표면 아우라(Figma `2024:20758`) — 같은 에셋 레이어를 쓴다.
if (stage == 6 || widget.evolutionImminent)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
@@ -1,4 +1,3 @@
import '../../home/home_screen.dart' show formatPop;
import '../eclipse_data.dart';
import '../../../core/i18n/app_locale.dart';
@@ -6,8 +5,8 @@ import '../../../core/i18n/app_locale.dart';
///
/// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 —
/// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*.
/// 이클립스 방어 참여는 같은 형식에 **참여량**을 싣는다. POP 이 아니다 — 방어는
/// POP 을 소모하지 않는 공동 Shake 참여다(설계서 v1.1 §9-#5).
/// 이클립스 방어는 **합류 1회**만 싣고 Shake 횟수는 싣지 않는다 — UX·FX v1.5 §5.1 「다른 팬의 행동은
/// 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」. 집약 수는 방어 화면의 유효 참여자 표시다.
///
/// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을
/// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다.
@@ -16,7 +15,7 @@ import '../../../core/i18n/app_locale.dart';
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
String liveEventMessage(LiveEvent event) => switch (event.kind) {
LiveEventKind.eclipseDefense =>
appL10n.liveEclipseDefense(event.nickname, formatPop(event.defense)),
appL10n.liveEclipseDefense(event.nickname),
LiveEventKind.artistHotTime =>
appL10n.liveHotTime(event.nickname),
LiveEventKind.heroGift =>
+24
View File
@@ -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,
+6 -4
View File
@@ -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!),
),
+20
View File
@@ -523,6 +523,26 @@ class _HomeBody extends StatelessWidget {
),
),
),
// Figma `2024:20758` — 행성 터치는 흔들림 1번 뒤 커뮤니티(팬덤 라운지)로.
onTapPlanet:
planetRepository == null ||
data.fandomId.isEmpty
? null
: () => Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(
data.fandomId,
),
watchPlanet: watchPlanet,
eclipse: eclipse,
initialTab: 1,
),
),
),
onOpenHistory: planetRepository == null
? null
: () => Navigator.of(context).push(
+56 -1
View File
@@ -8,6 +8,7 @@ import '../../../core/ui/hero_time_status.dart';
import 'growth_conditions.dart';
import 'dart:math' as math;
import 'package:flutter/services.dart' show HapticFeedback;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
@@ -38,6 +39,7 @@ class HomeHero extends StatefulWidget {
this.onHeroParticipate,
this.onOpenHistory,
this.onOpenPlanet,
this.onTapPlanet,
this.zap,
this.zapData,
this.ticker,
@@ -61,6 +63,9 @@ class HomeHero extends StatefulWidget {
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
final VoidCallback? onOpenPlanet;
/// 행성 본체 터치 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번, 커뮤니티 이동」.
final VoidCallback? onTapPlanet;
final String fandomId;
final PlanetWatch watchPlanet;
final VoidCallback? onHeroParticipate;
@@ -207,7 +212,9 @@ class _HomeHeroState extends State<HomeHero> {
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: LivePlanet25D(
child: _TapShake(
onTap: widget.onTapPlanet,
child: LivePlanet25D(
onOpenHistory: widget.onOpenHistory,
key: _planetKey,
fandomId: fandomId,
@@ -221,6 +228,7 @@ class _HomeHeroState extends State<HomeHero> {
),
glow: AppColors.neonViolet,
),
),
),
],
),
@@ -265,6 +273,7 @@ class _HomeHeroState extends State<HomeHero> {
Positioned.fill(
child: IgnorePointer(
child: ZapFeedback(
fandomName: widget.growth.fandomName,
touchSerial: zap.touchSerial,
pending: zap.pending > 0 || zap.sending,
result: zap.result,
@@ -1022,3 +1031,49 @@ class HomeZap extends StatelessWidget {
},
);
}
/// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic.
/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다.
class _TapShake extends StatefulWidget {
const _TapShake({required this.child, this.onTap});
final Widget child;
final VoidCallback? onTap;
@override
State<_TapShake> createState() => _TapShakeState();
}
class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixin {
late final _shake = AnimationController(vsync: this, duration: const Duration(milliseconds: 320));
@override
void dispose() {
_shake.dispose();
super.dispose();
}
Future<void> _tap() async {
final onTap = widget.onTap;
if (onTap == null || _shake.isAnimating) return;
HapticFeedback.lightImpact();
if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0);
if (mounted) onTap();
}
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onTap == null ? null : _tap,
child: AnimatedBuilder(
animation: _shake,
// 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠.
builder: (context, child) => Transform.translate(
offset: Offset(math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value), 0),
child: child,
),
child: widget.child,
),
);
}
@@ -192,6 +192,20 @@ class _RankingBody extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
if (data.refreshMinutes case final minutes?) ...[
Text(
[
if (data.updatedAt case final at?)
context.l10n.planetLastUpdated(
'${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}',
),
context.l10n.rankRefreshEvery('$minutes'),
].join(' · '),
style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500),
),
const SizedBox(height: AppSpace.s3),
],
if (data.mine case final me?) ...[
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
const SizedBox(height: AppSpace.s4),
+26 -1
View File
@@ -123,10 +123,29 @@ String countryName(String code) {
/// 랭킹 한 판.
class FpiRanking {
const FpiRanking({required this.rows, this.mine, this.mockNote});
const FpiRanking({
required this.rows,
this.mine,
this.mockNote,
this.updatedAt,
this.refreshMinutes,
});
final List<FpiRankRow> rows;
/// 마지막 갱신 시각 · 갱신 주기(분) — 별첨 1 ② 예외처리 「갱신 주기·마지막 갱신 시각 화면 안내」.
/// 행성 랭킹은 1분 집계(`fpiUpdatedAt`), 사용자 · 국가 랭킹은 5분 집계다.
final DateTime? updatedAt;
final int? refreshMinutes;
FpiRanking withUpdate(DateTime? at, int minutes) => FpiRanking(
rows: rows,
mine: mine,
mockNote: mockNote,
updatedAt: at,
refreshMinutes: minutes,
);
/// 내 줄(행성 랭킹이면 내 행성, 사용자 랭킹이면 나). 목록 밖에 따로 띄운다 —
/// Figma `546:14628` 「소속 행성 지표 랭킹 영역」 · `546:14668` 「나의 랭킹 영역」.
final FpiRankRow? mine;
@@ -251,8 +270,14 @@ class PlanetDetail {
this.fpiUpdatedAt,
this.joined = true,
this.growthPop = 0,
this.nebulaTargetPop = 1000,
this.nebulaTargetMembers = 10,
});
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
final int nebulaTargetPop;
final int nebulaTargetMembers;
/// 성운 누적 POP(`fandoms.growthPop`) — 성운 FPI 탭 「성운 성장률」의 분자.
final int growthPop;
@@ -14,7 +14,6 @@ import '../../core/ui/app_icons.dart';
import '../eclipse/eclipse_data.dart';
import '../eclipse/widgets/eclipse_banner.dart';
import '../home/home_screen.dart' show formatPop;
import '../home/home_data.dart' show defaultNebulaTargets;
import 'lounge_post_screens.dart';
import 'moderation.dart';
import 'planet_data.dart';
@@ -41,8 +40,12 @@ class PlanetDetailScreen extends StatefulWidget {
this.watchPlanet = noPlanetWatch,
required this.repository,
required this.eclipse,
this.initialTab = 0,
});
/// 처음 열 탭 — 0 FPI · 1 팬덤 라운지. HOME 행성 터치는 커뮤니티(라운지)로 연다(Figma `2024:20758`).
final int initialTab;
/// 성운 상세인가. 성운은 아직 정식 행성이 아니다(정책 §P-08).
final bool nebula;
final PlanetRepository repository;
@@ -129,6 +132,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
final top = MediaQuery.paddingOf(context).top;
return DefaultTabController(
length: 2,
initialIndex: widget.initialTab.clamp(0, 1),
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, _) => [
@@ -431,7 +435,13 @@ class _FpiTab extends StatelessWidget {
padding: const EdgeInsets.all(16),
sliver: SliverList.list(
children: [
_NebulaFpi(pop: detail.growthPop, repository: repository),
_NebulaFpi(
pop: detail.growthPop,
target: detail.nebulaTargetPop,
members: detail.members,
targetMembers: detail.nebulaTargetMembers,
repository: repository,
),
],
),
),
@@ -525,9 +535,20 @@ class _FpiTab extends StatelessWidget {
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
class _NebulaFpi extends StatelessWidget {
const _NebulaFpi({required this.pop, required this.repository});
const _NebulaFpi({
required this.pop,
required this.target,
required this.members,
required this.targetMembers,
required this.repository,
});
final int pop;
/// 전환 목표 — 서버 `config/policy`(B-10). 탄생은 POP · 창립 인원 **두 조건**이다.
final int target;
final int members;
final int targetMembers;
final PlanetRepository repository;
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
@@ -550,8 +571,7 @@ class _NebulaFpi extends StatelessWidget {
@override
Widget build(BuildContext context) {
final target = defaultNebulaTargets.pop;
final rate = pop * 100 ~/ target;
final rate = target <= 0 ? 0 : (pop * 100 ~/ target).clamp(0, 100);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -593,7 +613,7 @@ class _NebulaFpi extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: LinearProgressIndicator(
value: pop / target,
value: target <= 0 ? 0 : (pop / target).clamp(0.0, 1.0),
minHeight: 10,
backgroundColor: Colors.black.withValues(alpha: 0.3),
color: AppColors.cosmicTeal,
@@ -610,6 +630,12 @@ class _NebulaFpi extends StatelessWidget {
),
],
),
const SizedBox(height: 8),
// 인원 조건도 함께 — 탄생은 POP · 창립 인원 동시 충족(B-10).
Text(
'${context.l10n.growthFounders} ${formatPop(members)} / ${formatPop(targetMembers)}',
style: _b(12, const Color(0xFFA3A2A0)),
),
],
),
),
@@ -5,6 +5,7 @@ import '../../core/data/backend.dart';
import '../campaign/campaign_data.dart' show ContributorRank;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
import '../my/my_repository_firestore.dart';
import '../home/home_data.dart' show nebulaTargetsFrom;
import 'moderation.dart' show reportReasonCodes;
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
@@ -165,6 +166,8 @@ class FirestorePlanetRepository implements PlanetRepository {
.limit(1)
.get();
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
// 성운 전환 목표는 서버 판정과 같은 운영값을 읽는다(앱 상수 아님 · B-10).
final policyF = db.doc('config/policy').get();
_cursor = null;
final postsF = _page(pageSize);
@@ -173,6 +176,7 @@ class FirestorePlanetRepository implements PlanetRepository {
final memberSnap = await memberF;
final member = memberSnap.data() ?? const <String, dynamic>{};
final posts = await postsF;
final targets = nebulaTargetsFrom((await policyF).data());
final axes = _map(fandom, 'fpiAxes');
final ranks = _map(fandom, 'fpiRanks');
@@ -212,6 +216,8 @@ class FirestorePlanetRepository implements PlanetRepository {
isNebula: stage == null ? nebula : stage == 0,
members: fandom.intOr('memberCount'),
growthPop: fandom.intOr('growthPop'),
nebulaTargetPop: targets.pop,
nebulaTargetMembers: targets.members,
scores: [
for (final metric in FpiMetric.values)
FpiScore(
@@ -236,10 +242,22 @@ class FirestorePlanetRepository implements PlanetRepository {
);
}
/// 행성 랭킹 — 서버 1분 집계(`fnFpiAggregate`)가 적은 `fpiUpdatedAt` 중 가장 늦은 값이 마지막 갱신이다.
@override
Future<FpiRanking> planetRanking({
required FpiMetric metric,
int limit = 30,
}) async {
final ranking = await _planetRanking(metric: metric, limit: limit);
final me = (await _backend.db.doc('fandoms/$fandomId').get()).data();
return ranking.withUpdate(_time(me?['fpiUpdatedAt']), 1);
}
static DateTime? _time(Object? v) => v is Timestamp ? v.toDate() : null;
Future<FpiRanking> _planetRanking({
required FpiMetric metric,
required int limit,
}) async {
final axis = _axisKey(metric);
// 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」.
@@ -310,11 +328,22 @@ class FirestorePlanetRepository implements PlanetRepository {
);
}
/// 사용자 랭킹 — 서버 5분 집계(`fnRankingCountryAggregate`)가 팬덤 문서에 적는 `memberRankedAt`.
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) async {
final ranking = await _userRanking(metric: metric, fandomId: fandomId, limit: limit);
final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data();
return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5);
}
Future<FpiRanking> _userRanking({
required FpiMetric metric,
required String fandomId,
required int limit,
}) async {
final axis = _axisKey(metric);
final uid = _backend.requireUid;
@@ -470,6 +499,8 @@ class FirestorePlanetRepository implements PlanetRepository {
return FpiRanking(
rows: list,
mine: list.where((r) => r.isMine).firstOrNull,
updatedAt: _time(doc.data()?['updatedAt']),
refreshMinutes: 5,
);
}
+3 -1
View File
@@ -201,7 +201,7 @@
"eclipseBannerDefense": "Valid participants {count} / {target}",
"timeHoursMinutesLeft": "{hours}h {minutes}m left",
"timeMinutesLeft": "{minutes}m left",
"liveEclipseDefense": "[LIVE] {name} joined the eclipse defense! (+{count})",
"liveEclipseDefense": "[LIVE] {name} joined the eclipse defense!",
"liveHotTime": "[LIVE] {name}'s Artist Hot Time has started!",
"liveHeroGift": "[LIVE] A Hero's Gift from {name} has arrived!",
"liveCampaign": "[LIVE] {name} backed a campaign!",
@@ -235,6 +235,7 @@
"zapDone": "Contributed",
"zapDonePop": "Contributed {pop} POP",
"growthFounders": "Founding members",
"zapReflected": "{pop} POP added to {fandom}'s growth",
"growthNebulaGoalPassed": "The goal period has passed · Meet the conditions and the planet will be born",
"growthFinalStage": "Final stage",
"growthPromotionPending": "Awaiting promotion",
@@ -486,6 +487,7 @@
"rankEmpty": "No rankings yet",
"rankScope": "{scope} ranking",
"rankLoadFailed": "Couldn't load the ranking",
"rankRefreshEvery": "Updates every {minutes} min",
"rankMeSuffix": "{name} (me)",
"reportDefamation": "Artist slander or defamation",
"reportInappropriate": "Inappropriate content",
+3 -1
View File
@@ -201,7 +201,7 @@
"eclipseBannerDefense": "Participantes válidos {count} / {target}",
"timeHoursMinutesLeft": "Quedan {hours} h {minutes} min",
"timeMinutesLeft": "Quedan {minutes} min",
"liveEclipseDefense": "[LIVE] ¡{name} se unió a la defensa del eclipse! (+{count})",
"liveEclipseDefense": "[LIVE] ¡{name} se unió a la defensa del eclipse!",
"liveHotTime": "[LIVE] ¡Comenzó el Artist Hot Time de {name}!",
"liveHeroGift": "[LIVE] ¡Llegó el Regalo del Héroe de {name}!",
"liveCampaign": "[LIVE] ¡{name} apoyó una campaña!",
@@ -235,6 +235,7 @@
"zapDone": "Contribución completada",
"zapDonePop": "{pop} POP contribuidos",
"growthFounders": "Miembros fundadores",
"zapReflected": "{pop} POP se sumaron al crecimiento de {fandom}",
"growthNebulaGoalPassed": "El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta",
"growthFinalStage": "Etapa final",
"growthPromotionPending": "Ascenso pendiente",
@@ -486,6 +487,7 @@
"rankEmpty": "Aún no hay ranking",
"rankScope": "Ranking {scope}",
"rankLoadFailed": "No se pudo cargar el ranking",
"rankRefreshEvery": "Se actualiza cada {minutes} min",
"rankMeSuffix": "{name} (yo)",
"reportDefamation": "Difamación o insultos al artista",
"reportInappropriate": "Contenido inapropiado",
+3 -1
View File
@@ -201,7 +201,7 @@
"eclipseBannerDefense": "有効参加 {count} / {target}人",
"timeHoursMinutesLeft": "残り{hours}時間{minutes}分",
"timeMinutesLeft": "残り{minutes}分",
"liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!(+{count})",
"liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!",
"liveHotTime": "[LIVE] {name}さんのアーティストホットタイムが始まりました!",
"liveHeroGift": "[LIVE] {name}さんの英雄のギフトが届きました!",
"liveCampaign": "[LIVE] {name}さんがキャンペーンを支援しました!",
@@ -235,6 +235,7 @@
"zapDone": "貢献完了",
"zapDonePop": "{pop} POP 貢献完了",
"growthFounders": "創立メンバー",
"zapReflected": "{pop} POP が {fandom} の成長に反映されました",
"growthNebulaGoalPassed": "目標期間が過ぎました · 条件を満たすと惑星が誕生します",
"growthFinalStage": "最終ステージ",
"growthPromotionPending": "昇格確定待ち",
@@ -486,6 +487,7 @@
"rankEmpty": "まだ集計されたランキングはありません",
"rankScope": "{scope}ランキング",
"rankLoadFailed": "ランキングを読み込めませんでした",
"rankRefreshEvery": "{minutes}分ごとに更新",
"rankMeSuffix": "{name}(自分)",
"reportDefamation": "アーティストへの誹謗・名誉毀損",
"reportInappropriate": "不適切なコンテンツ",
+20 -4
View File
@@ -422,14 +422,11 @@
}
}
},
"liveEclipseDefense": "[LIVE] {name}님이 이클립스 방어에 참여했습니다! (+{count})",
"liveEclipseDefense": "[LIVE] {name}님이 이클립스 방어에 합류했습니다!",
"@liveEclipseDefense": {
"placeholders": {
"name": {
"type": "String"
},
"count": {
"type": "String"
}
}
},
@@ -531,6 +528,17 @@
}
},
"growthFounders": "창립 멤버",
"zapReflected": "{pop} POP 이 {fandom} 성장에 반영되었습니다",
"@zapReflected": {
"placeholders": {
"pop": {
"type": "String"
},
"fandom": {
"type": "String"
}
}
},
"growthNebulaGoalPassed": "목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요",
"growthFinalStage": "최종 Stage",
"growthPromotionPending": "승급 확정 대기",
@@ -966,6 +974,14 @@
}
},
"rankLoadFailed": "랭킹을 불러오지 못했습니다",
"rankRefreshEvery": "{minutes}분마다 갱신",
"@rankRefreshEvery": {
"placeholders": {
"minutes": {
"type": "String"
}
}
},
"rankMeSuffix": "{name} (나)",
"@rankMeSuffix": {
"placeholders": {
+14 -2
View File
@@ -1313,8 +1313,8 @@ abstract class AppLocalizations {
/// No description provided for @liveEclipseDefense.
///
/// In ko, this message translates to:
/// **'[LIVE] {name}님이 이클립스 방어에 참여했습니다! (+{count})'**
String liveEclipseDefense(String name, String count);
/// **'[LIVE] {name}님이 이클립스 방어에 합류했습니다!'**
String liveEclipseDefense(String name);
/// No description provided for @liveHotTime.
///
@@ -1514,6 +1514,12 @@ abstract class AppLocalizations {
/// **'창립 멤버'**
String get growthFounders;
/// No description provided for @zapReflected.
///
/// In ko, this message translates to:
/// **'{pop} POP 이 {fandom} 성장에 반영되었습니다'**
String zapReflected(String pop, String fandom);
/// No description provided for @growthNebulaGoalPassed.
///
/// In ko, this message translates to:
@@ -3020,6 +3026,12 @@ abstract class AppLocalizations {
/// **'랭킹을 불러오지 못했습니다'**
String get rankLoadFailed;
/// No description provided for @rankRefreshEvery.
///
/// In ko, this message translates to:
/// **'{minutes}분마다 갱신'**
String rankRefreshEvery(String minutes);
/// No description provided for @rankMeSuffix.
///
/// In ko, this message translates to:
+12 -2
View File
@@ -728,8 +728,8 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String liveEclipseDefense(String name, String count) {
return '[LIVE] $name joined the eclipse defense! (+$count)';
String liveEclipseDefense(String name) {
return '[LIVE] $name joined the eclipse defense!';
}
@override
@@ -850,6 +850,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get growthFounders => 'Founding members';
@override
String zapReflected(String pop, String fandom) {
return '$pop POP added to $fandom\'s growth';
}
@override
String get growthNebulaGoalPassed =>
'The goal period has passed · Meet the conditions and the planet will be born';
@@ -1680,6 +1685,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rankLoadFailed => 'Couldn\'t load the ranking';
@override
String rankRefreshEvery(String minutes) {
return 'Updates every $minutes min';
}
@override
String rankMeSuffix(String name) {
return '$name (me)';
+12 -2
View File
@@ -738,8 +738,8 @@ class AppLocalizationsEs extends AppLocalizations {
}
@override
String liveEclipseDefense(String name, String count) {
return '[LIVE] ¡$name se unió a la defensa del eclipse! (+$count)';
String liveEclipseDefense(String name) {
return '[LIVE] ¡$name se unió a la defensa del eclipse!';
}
@override
@@ -860,6 +860,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get growthFounders => 'Miembros fundadores';
@override
String zapReflected(String pop, String fandom) {
return '$pop POP se sumaron al crecimiento de $fandom';
}
@override
String get growthNebulaGoalPassed =>
'El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta';
@@ -1688,6 +1693,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get rankLoadFailed => 'No se pudo cargar el ranking';
@override
String rankRefreshEvery(String minutes) {
return 'Se actualiza cada $minutes min';
}
@override
String rankMeSuffix(String name) {
return '$name (yo)';
+12 -2
View File
@@ -698,8 +698,8 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String liveEclipseDefense(String name, String count) {
return '[LIVE] $nameさんがイクリプス防衛に参加しました!(+$count)';
String liveEclipseDefense(String name) {
return '[LIVE] $nameさんがイクリプス防衛に参加しました!';
}
@override
@@ -817,6 +817,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get growthFounders => '創立メンバー';
@override
String zapReflected(String pop, String fandom) {
return '$pop POP が $fandom の成長に反映されました';
}
@override
String get growthNebulaGoalPassed => '目標期間が過ぎました · 条件を満たすと惑星が誕生します';
@@ -1622,6 +1627,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get rankLoadFailed => 'ランキングを読み込めませんでした';
@override
String rankRefreshEvery(String minutes) {
return '$minutes分ごとに更新';
}
@override
String rankMeSuffix(String name) {
return '$name(自分)';
+12 -2
View File
@@ -698,8 +698,8 @@ class AppLocalizationsKo extends AppLocalizations {
}
@override
String liveEclipseDefense(String name, String count) {
return '[LIVE] $name님이 이클립스 방어에 참여했습니다! (+$count)';
String liveEclipseDefense(String name) {
return '[LIVE] $name님이 이클립스 방어에 합류했습니다!';
}
@override
@@ -817,6 +817,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get growthFounders => '창립 멤버';
@override
String zapReflected(String pop, String fandom) {
return '$pop POP 이 $fandom 성장에 반영되었습니다';
}
@override
String get growthNebulaGoalPassed => '목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요';
@@ -1623,6 +1628,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get rankLoadFailed => '랭킹을 불러오지 못했습니다';
@override
String rankRefreshEvery(String minutes) {
return '$minutes분마다 갱신';
}
@override
String rankMeSuffix(String name) {
return '$name (나)';
+12 -2
View File
@@ -698,8 +698,8 @@ class AppLocalizationsZh extends AppLocalizations {
}
@override
String liveEclipseDefense(String name, String count) {
return '[LIVE] $name 参与了日蚀防御!(+$count)';
String liveEclipseDefense(String name) {
return '[LIVE] $name 加入了日蚀防御!';
}
@override
@@ -817,6 +817,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get growthFounders => '创始成员';
@override
String zapReflected(String pop, String fandom) {
return '$pop POP 已计入 $fandom 的成长';
}
@override
String get growthNebulaGoalPassed => '目标期限已过 · 满足条件即可诞生行星';
@@ -1621,6 +1626,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get rankLoadFailed => '无法加载排名';
@override
String rankRefreshEvery(String minutes) {
return '每$minutes分钟更新';
}
@override
String rankMeSuffix(String name) {
return '$name(我)';
+3 -1
View File
@@ -201,7 +201,7 @@
"eclipseBannerDefense": "有效参与 {count} / {target} 人",
"timeHoursMinutesLeft": "剩余 {hours} 小时 {minutes} 分",
"timeMinutesLeft": "剩余 {minutes} 分",
"liveEclipseDefense": "[LIVE] {name} 参与了日蚀防御!(+{count})",
"liveEclipseDefense": "[LIVE] {name} 加入了日蚀防御!",
"liveHotTime": "[LIVE] {name} 的艺人热力时刻开始了!",
"liveHeroGift": "[LIVE] {name} 的英雄礼物到了!",
"liveCampaign": "[LIVE] {name} 支持了一个活动!",
@@ -235,6 +235,7 @@
"zapDone": "贡献完成",
"zapDonePop": "已贡献 {pop} POP",
"growthFounders": "创始成员",
"zapReflected": "{pop} POP 已计入 {fandom} 的成长",
"growthNebulaGoalPassed": "目标期限已过 · 满足条件即可诞生行星",
"growthFinalStage": "最终阶段",
"growthPromotionPending": "等待晋级确认",
@@ -486,6 +487,7 @@
"rankEmpty": "暂无排名数据",
"rankScope": "{scope}排名",
"rankLoadFailed": "无法加载排名",
"rankRefreshEvery": "每{minutes}分钟更新",
"rankMeSuffix": "{name}(我)",
"reportDefamation": "诽谤艺人及损害名誉",
"reportInappropriate": "不当内容",
+7 -1
View File
@@ -1157,7 +1157,7 @@ class RemoteAppLocalizations extends AppLocalizations {
String liveCampaign(String name) => _fill('liveCampaign', {'name': name}) ?? _base.liveCampaign(name);
@override
String liveEclipseDefense(String name, String count) => _fill('liveEclipseDefense', {'name': name, 'count': count}) ?? _base.liveEclipseDefense(name, count);
String liveEclipseDefense(String name) => _fill('liveEclipseDefense', {'name': name}) ?? _base.liveEclipseDefense(name);
@override
String liveHeroGift(String name) => _fill('liveHeroGift', {'name': name}) ?? _base.liveHeroGift(name);
@@ -2035,6 +2035,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get rankPlanetEmpty => _remote['rankPlanetEmpty'] ?? _base.rankPlanetEmpty;
@override
String rankRefreshEvery(String minutes) => _fill('rankRefreshEvery', {'minutes': minutes}) ?? _base.rankRefreshEvery(minutes);
@override
String rankScope(String scope) => _fill('rankScope', {'scope': scope}) ?? _base.rankScope(scope);
@@ -2461,6 +2464,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get zapNotEnoughPop => _remote['zapNotEnoughPop'] ?? _base.zapNotEnoughPop;
@override
String zapReflected(String pop, String fandom) => _fill('zapReflected', {'pop': pop, 'fandom': fandom}) ?? _base.zapReflected(pop, fandom);
@override
String get zapRetrySame => _remote['zapRetrySame'] ?? _base.zapRetrySame;
}