라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user