HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,489 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import 'planet_data.dart';
import 'widgets/action_sheet.dart';
import '../../core/i18n/app_locale.dart';
/// 지표별 **행성** 랭킹 — Figma `546:14589`~`546:14639`.
///
/// 명세 `546:14591` — *타이틀 = 선택 지표명 값 + RANKING*
/// 명세 `546:14628` — *소속 행성 지표 랭킹 영역 — 순위 · 순위 동향 · 행성명 · 지표 수치*
/// 명세 `546:14639` — *전체 지표 랭킹 영역 — 최초 30개 · 스크롤 시 10개씩 ·*
/// *동작: 사용자 랭킹 화면 이동*
///
/// **지표 카드에서 여기로 온다.** 지표 카드의 값·전체 순위는 저장소의 같은
/// `fpiAxes[축]`·`fpiRanks[축]` 이므로, 아래 「소속 행성」 줄과 카드가 언제나
/// 같은 수치를 띄운다(발주사 검토회신서 4절).
///
/// 모집단은 별첨 B 확정 — *정식 행성 중 최근 7 일 유효 활동 존재 행성*.
class FpiRankingScreen extends StatefulWidget {
const FpiRankingScreen({
super.key,
required this.metric,
required this.repository,
});
final FpiMetric metric;
final PlanetRepository repository;
@override
State<FpiRankingScreen> createState() => _FpiRankingScreenState();
}
class _FpiRankingScreenState extends State<FpiRankingScreen> {
late final Future<FpiRanking> _ranking = widget.repository.planetRanking(
metric: widget.metric,
);
void _openUsers(FpiRankRow planet) =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => FpiUserRankingScreen(
metric: widget.metric,
fandomId: planet.id,
planetName: planet.name,
repository: widget.repository,
),
),
);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text('${widget.metric.label} RANKING')),
body: _RankingBody(
future: _ranking,
// 명세 `546:14628` — 소속 행성 영역은 목록과 별개로 상단에 고정한다.
myLabel: context.l10n.rankMyPlanet(widget.metric.label),
emptyLabel: context.l10n.rankPlanetEmpty,
onTapRow: _openUsers,
planets: true,
),
);
}
/// 랭킹 필터 — 명세 `546:14690` *실시간 · 국가별 · 취소 · 디폴트 실시간 · 단일 선택*.
enum FpiRankScope {
live,
byCountry;
String get label => switch (this) {
live => appL10n.rankScopeLive,
byCountry => appL10n.rankScopeCountry,
};
}
/// 지표별 **사용자** 랭킹 — Figma `546:14646`~`546:14713`.
///
/// 명세 `546:14668` — *나의 랭킹 영역 — 내 순위 · 순위 변동값 · 닉네임 · 기여 값*
/// 명세 `546:14674` — *기여 값에 따른 랭킹 소팅 · 실시간 데이터 기준*
/// 명세 `546:14713` — *국가별 — 순위 · 순위동향 · 국가명 · 국가 지표 값*
///
/// 행성 랭킹의 한 줄을 눌러 들어온다. 캠페인 기여 랭킹과 구조가 같지만
/// **필터 축이 다르다** — 캠페인은 기간(실시간/일간/주간), 여기는 범위
/// (실시간/국가별)다. 한 화면으로 합치면 어느 필터가 뜨는지를 다시 분기해야 한다.
class FpiUserRankingScreen extends StatefulWidget {
const FpiUserRankingScreen({
super.key,
required this.metric,
required this.fandomId,
required this.planetName,
required this.repository,
});
final FpiMetric metric;
final String fandomId;
final String planetName;
final PlanetRepository repository;
@override
State<FpiUserRankingScreen> createState() => _FpiUserRankingScreenState();
}
class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
FpiRankScope _scope = FpiRankScope.live;
late Future<FpiRanking> _ranking = _load();
Future<FpiRanking> _load() => switch (_scope) {
FpiRankScope.live => widget.repository.userRanking(
metric: widget.metric,
fandomId: widget.fandomId,
),
FpiRankScope.byCountry => widget.repository.countryRanking(
metric: widget.metric,
),
};
@override
Widget build(BuildContext context) => Scaffold(
// 시안 `373:436695` — 제목 「USER RANKING」, 범위 필터는 본문 「실시간 랭킹 ⌄」.
appBar: AppBar(title: const Text('USER RANKING')),
body: _RankingBody(
future: _ranking,
// 국가별에서는 「나의 랭킹 영역」이 내 국가 행이다(546:14713).
myLabel: switch (_scope) {
FpiRankScope.live => context.l10n.rankMine(widget.metric.label),
FpiRankScope.byCountry => context.l10n.rankMyCountry(
widget.metric.label,
),
},
emptyLabel: context.l10n.rankEmpty,
scopeLabel: context.l10n.rankScope(_scope.label),
onPickScope: _pickScope,
),
);
Future<void> _pickScope() async {
FpiRankScope? picked;
await showActionSheet(context, [
for (final scope in FpiRankScope.values)
ActionSheetItem(scope.label, () => picked = scope, destructive: false),
]);
final next = picked;
if (next != null && next != _scope && mounted) {
setState(() {
_scope = next;
_ranking = _load();
});
}
}
}
/// 두 랭킹 화면이 같은 몸통을 쓴다 — 명세가 「내 줄 + 목록」으로 같다.
///
/// 시안 `Frame 1984081171`(안쪽 16 · 간격 24) — 제목 24/500 + 내 줄 카드,
/// 사용자 랭킹은 「실시간 랭킹 ⌄」 제목과 1~3위 시상대가 목록 앞에 온다.
class _RankingBody extends StatelessWidget {
const _RankingBody({
required this.future,
required this.myLabel,
required this.emptyLabel,
this.onTapRow,
this.planets = false,
this.scopeLabel,
this.onPickScope,
});
final Future<FpiRanking> future;
final String myLabel;
final String emptyLabel;
final ValueChanged<FpiRankRow>? onTapRow;
/// 행성 랭킹이면 내 줄 아바타가 행성 그림이다.
final bool planets;
/// 사용자 랭킹의 범위 제목과 그 선택.
final String? scopeLabel;
final VoidCallback? onPickScope;
@override
Widget build(BuildContext context) => FutureBuilder<FpiRanking>(
future: future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final data = snap.data;
if (data == null) {
return Center(child: Text(context.l10n.rankLoadFailed));
}
final podium = scopeLabel != null && data.rows.length >= 3;
final rows = podium ? data.rows.skip(3).toList() : data.rows;
return ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 실데이터가 아니면 숨기지 않고 그대로 알린다.
if (data.mockNote case final note?) ...[
MockBadge(note: note),
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),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(8),
),
child: _RankRow(row: me, index: -1, planet: planets, mark: false),
),
const SizedBox(height: 24),
],
if (scopeLabel case final label?) ...[
InkWell(
onTap: onPickScope,
child: Row(
children: [
Text(
label,
style: _rt(
18,
AppColors.onCosmic,
FontWeight.w600,
height: null,
),
),
const SizedBox(width: 4),
const Icon(
Icons.keyboard_arrow_down,
size: 20,
color: AppColors.onCosmic,
),
],
),
),
const SizedBox(height: 12),
],
if (data.rows.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Center(child: Text(emptyLabel)),
),
if (podium) ...[
_Podium(rows: data.rows.take(3).toList()),
const SizedBox(height: 12),
],
for (final (i, row) in rows.indexed)
InkWell(
onTap: onTapRow == null ? null : () => onTapRow!(row),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: _RankRow(row: row, index: i),
),
),
],
);
},
);
}
TextStyle _rt(
double size,
Color color,
FontWeight weight, {
double? height = 1,
}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: height,
letterSpacing: height == null ? 0 : size * -0.025,
color: color,
);
/// 랭킹 수치 — 시안 「782.2K」 · 「1.2K」 · 「3K」 · 「98」.
String _rankValue(int v) {
String trim(String s) => s.endsWith('.0') ? s.substring(0, s.length - 2) : s;
if (v >= 1000000) return '${trim((v / 1000000).toStringAsFixed(1))}M';
if (v >= 1000) return '${trim((v / 1000).toStringAsFixed(1))}K';
return '$v';
}
/// 아바타 자리 — 서버에 사진이 없어 시안의 색 원을 순서대로 쓴다.
const _avatarColors = [
Color(0xFF000000),
Color(0xFF7160E8),
Color(0xFFF542E6),
Color(0xFFC8D48A),
Color(0xFFF2D0F5),
Color(0xFF8E1EA8),
Color(0xFFF2C2B8),
Color(0xFFF5F2B0),
Color(0xFFD2F5B8),
];
Widget _avatar(int index, double size, {bool planet = false}) => planet
? Image.asset(
'assets/figma/planet-detail-hero.png',
width: size,
height: size,
)
: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _avatarColors[index.abs() % _avatarColors.length],
),
);
/// 순위 동향 — ▲n #f28180 · ▼n #6866f3 · 변동 없음 「-」 #777777(10/500).
class _Change extends StatelessWidget {
const _Change({required this.change});
final int change;
@override
Widget build(BuildContext context) {
if (change == 0)
return Text(
'-',
style: _rt(10, const Color(0xFF777777), FontWeight.w500),
);
final color = change > 0 ? const Color(0xFFF28180) : AppColors.neonViolet;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(change > 0 ? '▲' : '▼', style: _rt(8, color, FontWeight.w500)),
const SizedBox(width: 2),
Text('${change.abs()}', style: _rt(10, color, FontWeight.w500)),
],
);
}
}
/// 랭킹 한 줄 — 순위 · 동향 | 28 | 아바타 40(원 36) · 6 · 이름 #979b9e | 수치 16/600.
class _RankRow extends StatelessWidget {
const _RankRow({
required this.row,
required this.index,
this.planet = false,
this.mark = true,
});
final FpiRankRow row;
final int index;
final bool planet;
/// 목록 안 내 행에 「(나)」를 붙인다. 위 내 줄 카드는 시안대로 이름만 둔다.
final bool mark;
@override
Widget build(BuildContext context) => SizedBox(
height: 40,
child: Row(
children: [
SizedBox(
width: 21,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${row.rank}',
style: _rt(14, AppColors.onCosmic, FontWeight.w600),
),
const SizedBox(height: 2),
_Change(change: row.change),
],
),
),
const SizedBox(width: 22),
// 사본 — 40 프레임 가운데 지름 36 원.
SizedBox.square(
dimension: 40,
child: Center(child: _avatar(index, 36, planet: planet)),
),
const SizedBox(width: 6),
Expanded(
child: Text(
row.isMine && mark ? context.l10n.rankMeSuffix(row.name) : row.name,
overflow: TextOverflow.ellipsis,
style: _rt(14, const Color(0xFF979B9E), FontWeight.w500),
),
),
Text(
_rankValue(row.value),
style: _rt(16, AppColors.onCosmic, FontWeight.w600, height: 1.4),
),
],
),
);
}
/// 1~3위 시상대 — 가운데 1위, 양옆 2·3위는 20 내려 앉는다. 왕관 24(금 #fea300 ·
/// 은 #acacac · 동 #9c724a) 아래 아바타 80, 이름 14/600 · 수치 16/600.
class _Podium extends StatelessWidget {
const _Podium({required this.rows});
final List<FpiRankRow> rows;
static const _crown = [
Color(0xFFFEA300),
Color(0xFFACACAC),
Color(0xFF9C724A),
];
Widget _place(BuildContext context, int i) => SizedBox(
width: 80,
child: Column(
children: [
if (i > 0) const SizedBox(height: 20),
SizedBox.square(
dimension: 24,
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'assets/figma/icon-crown.png',
width: 24,
height: 24,
color: _crown[i],
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'${i + 1}',
style: _rt(10, AppColors.onCosmic, FontWeight.w900),
),
),
],
),
),
_avatar(i + 3, 80),
const SizedBox(height: 8),
Text(
rows[i].isMine ? context.l10n.rankMeSuffix(rows[i].name) : rows[i].name,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: _rt(14, AppColors.onCosmic, FontWeight.w600),
),
const SizedBox(height: 4),
Text(
_rankValue(rows[i].value),
style: _rt(16, AppColors.onCosmic, FontWeight.w600, height: 1.4),
),
],
),
);
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_place(context, 1),
const SizedBox(width: 24),
_place(context, 0),
const SizedBox(width: 24),
_place(context, 2),
],
);
}
/// 실데이터가 아닌 자리를 숨기지 않고 그대로 알리는 배지 — 대조표 3-5.
/// 랭킹 화면과 행성 상세 FPI 탭이 같은 배지를 쓴다.
class MockBadge extends StatelessWidget {
const MockBadge({super.key, required this.note});
final String note;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(AppSpace.s4, AppSpace.s3, AppSpace.s4, 0),
padding: const EdgeInsets.all(AppSpace.s3),
decoration: BoxDecoration(
color: AppColors.cosmicVoid,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.neonViolet),
),
child: Text(
'MOCK · $note',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppColors.neonViolet),
),
);
}
@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sensors_plus/sensors_plus.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/data/backend.dart';
import '../../core/ui/live_stream.dart';
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
class HeroParticipationSheet extends StatefulWidget {
const HeroParticipationSheet({super.key, required this.fandomId,
required this.watch, required this.repository, this.accelerometer});
final String fandomId;
final PlanetWatch watch;
final PlanetRepository repository;
final Stream<AccelerometerEvent>? accelerometer;
@override
State<HeroParticipationSheet> createState() => _HeroParticipationSheetState();
}
class _HeroParticipationSheetState extends State<HeroParticipationSheet>
with WidgetsBindingObserver {
StreamSubscription<AccelerometerEvent>? _sensor;
PlanetVisualState? _state;
final _received = <String, HeroDropReceipt>{};
final _unavailable = <String>{};
String? _notice;
bool _accessDenied = false;
String? _pendingId;
bool _sending = false;
bool _failed = false;
bool _foreground = true;
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((event) {
final magnitude = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
final now = DateTime.now();
if (!magnitude.isFinite || magnitude < 18 || now.difference(_lastShake).inMilliseconds < 220) return;
_lastShake = now;
if (!_failed) _claim();
}, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다.
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_foreground = state == AppLifecycleState.resumed;
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_sensor?.cancel();
super.dispose();
}
String? get _next {
final state = _state;
if (_accessDenied || state?.isHeroTime != true || state?.heroEndsAt?.isAfter(DateTime.now()) != true) return null;
final ids = (state?.heroEventIds ?? {}).where((id) => !_received.containsKey(id) && !_unavailable.contains(id)).toList()..sort();
return ids.firstOrNull;
}
Future<void> _claim() async {
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true) return;
// 응답 유실 재확인은 이벤트 종료 뒤에도 같은 멱등키로 요청한다.
final id = _pendingId ?? _next;
if (id == null) return;
setState(() { _sending = true; _failed = false; _notice = null; _pendingId = id; });
try {
final receipt = await widget.repository.claimHeroDrop(id);
if (!mounted) return;
setState(() { _received[id] = receipt; _pendingId = null; });
if (_foreground) HapticFeedback.lightImpact();
} catch (error) {
if (!mounted) return;
final code = error is BackendException ? error.code : null;
setState(() {
_notice = switch (code) {
'E-POP-02' => context.l10n.heroGiftUnavailable,
'E-POP-03' => context.l10n.heroGiftAlready,
'E-AUTH-03' => context.l10n.heroGiftRelogin,
'E-PERM-01' => context.l10n.heroGiftNoPermission,
_ => null,
};
_failed = _notice == null;
if (!_failed) {
_pendingId = null;
_unavailable.add(id);
_accessDenied = code == 'E-AUTH-03' || code == 'E-PERM-01';
}
});
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(widget.fandomId),
create: () => widget.watch(widget.fandomId),
builder: (context, state) {
_state = state;
final available = _next != null;
final total = _received.values.fold<int>(0, (sum, r) => sum + r.amount);
return SafeArea(child: Padding(
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('HERO TIME · ZAP+', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Text(context.l10n.heroGiftHowTo),
Text(context.l10n.heroGiftPopNote),
const SizedBox(height: 16),
if (_received.isNotEmpty)
Semantics(liveRegion: true, child: Text(context.l10n.heroGiftReceived('$total'), style: Theme.of(context).textTheme.titleMedium)),
if (_notice case final notice?) Semantics(liveRegion: true, child: Text(notice)),
if (_failed) Text(context.l10n.heroGiftUnknown),
if (!available && !_sending && !_failed && _notice == null)
Text(_received.isEmpty ? context.l10n.heroGiftNone : context.l10n.heroGiftConfirmed),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _sending || (!available && !_failed) ? null : _claim,
icon: const Icon(Icons.auto_awesome),
label: Text(_sending ? context.l10n.heroGiftChecking : _failed ? context.l10n.heroGiftRetry : context.l10n.heroGiftTap),
),
]),
));
},
);
}
@@ -0,0 +1,791 @@
import 'package:firebase_core/firebase_core.dart' show FirebaseException;
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/relative_time.dart';
import '../campaign/campaign_share.dart' show campaignShareText;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
import '../my/photo_picker.dart';
import '../translation/translation_comments.dart';
import '../translation/translation_data.dart';
import 'moderation.dart';
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
/// 글 상세 · 글 쓰기 — 화면 대조 원장 F3.
///
/// Figma 노드
/// - `244:22509` Galaxy/행성 상세/글 상세 · `373:439127` 홈/…/글 상세
/// - `244:21746` Galaxy/행성 상세_팬덤 라운지/글 쓰기 · `373:437905` 홈/…/글 쓰기
/// - 신고·차단(`252:162674` · `252:163306` · `251:51469`)은 [ReportScreen] 공용
/// 글 쓰기가 돌려주는 값.
class PostDraft {
const PostDraft({
required this.title,
required this.body,
this.photos = const [],
});
final String title;
final String body;
/// 첨부 사진. 1차 검증([checkLoungePhotos])을 지난 것만 담긴다.
final List<PickedPhoto> photos;
}
/// 라운지 쓰기 실패를 사용자 문구로. 사유가 있는 실패는 그 문구를, 규칙 거부는
/// 권한 안내를, 나머지는 재시도 안내를 띄운다.
String loungeError(Object error) => switch (error) {
PhotoRejected(:final message) => message,
BackendException(:final message) => message,
FirebaseException(code: 'permission-denied') => appL10n.loungeMembersOnly,
_ => appL10n.loungeFailed,
};
/// 라운지 글 공유 — 시스템 공유 시트로 본문 문구를 보낸다.
Future<void> shareLoungePost(LoungePost post) => SharePlus.instance.share(
ShareParams(
text: campaignShareText(
title: loungePostText(post),
topic: appL10n.loungeTitle,
),
),
);
void _toast(BuildContext context, String message) =>
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
/// 글 한 편의 본문. 설계서 §2.6 `posts` 에는 제목이 없어(서버 글은 [LoungePost.title]
/// 이 빈 값) Figma 도 본문 한 덩어리만 둔다. 표본 글처럼 제목이 있으면 첫 줄로 붙인다.
String loungePostText(LoungePost p) =>
[p.title, p.body].where((s) => s.isNotEmpty).join('\n');
/// Figma 글 문구 공통 — 42dot Sans 500 · 행간 130% · 자간 -2.5%.
TextStyle loungeText(double size, Color color) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: size * -0.025,
color: color,
);
/// 에셋 경로 또는 URL.
ImageProvider loungeImage(String src) =>
src.startsWith('http') ? NetworkImage(src) : AssetImage(src);
/// 작성자 줄 — 아바타 26 · 간격 9 · 닉네임 ABeeZee 12 / 시각 10 #767676.
class LoungeAuthorRow extends StatelessWidget {
const LoungeAuthorRow({super.key, required this.post, this.trailing});
final LoungePost post;
final Widget? trailing;
@override
Widget build(BuildContext context) => Row(
children: [
CircleAvatar(
radius: 13,
backgroundColor: const Color(0xFFD9D9D9),
backgroundImage: switch (post.authorPhoto) {
final p? => loungeImage(p),
null => null,
},
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.authorName,
style: loungeText(12, AppColors.onCosmic).copyWith(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
),
),
Text(
relativeTime(post.createdAt),
style: loungeText(10, AppColors.onCosmicMuted),
),
],
),
),
?trailing,
],
);
}
/// 좋아요 · 댓글 수. 수가 0 이면 아이콘만 둔다(Figma 글 상세 첫 화면 `244:22509`).
class LoungeCounts extends StatelessWidget {
const LoungeCounts({
super.key,
required this.post,
required this.icon,
required this.color,
this.onLike,
});
final LoungePost post;
final double icon;
final Color color;
/// 하트를 누르면 좋아요를 켜고 끈다. null 이면 수만 보인다.
final VoidCallback? onLike;
@override
Widget build(BuildContext context) {
Widget item(String asset, int n, [Color? tint]) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(asset, width: icon, height: icon, color: tint ?? color),
if (n > 0) ...[
const SizedBox(width: 4),
Text(
'$n',
style: loungeText(icon <= 14 ? 12 : 14, color).copyWith(height: 1),
),
],
],
);
final heart = item(
'assets/figma/icon-heart.png',
post.likes,
post.liked ? AppColors.cosmicAccent : null,
);
return Row(
children: [
if (onLike == null)
heart
else
Semantics(
button: true,
label: post.liked ? context.l10n.likeCancel : context.l10n.like,
child: InkResponse(onTap: onLike, radius: 18, child: heart),
),
const SizedBox(width: 12),
item('assets/figma/icon-message-circle.png', post.comments),
],
);
}
}
/// 글 상세. 좋아요 · 댓글 · 공유 · 신고 · 차단 · 내 글 수정 · 삭제가 여기서 일어난다.
///
/// 바뀐 글은 [onChanged] 로 목록에 알린다 — 목록이 같은 글을 다시 읽지 않아도
/// 수가 맞는다. `null` 을 보내면 글이 목록에서 빠진다(삭제 · 차단).
class LoungePostScreen extends StatefulWidget {
const LoungePostScreen({
super.key,
required this.post,
this.repository = const SamplePlanetRepository(),
this.onChanged,
this.translation,
});
final LoungePost post;
final PlanetRepository repository;
final ValueChanged<LoungePost?>? onChanged;
/// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다.
final TranslationRepository? translation;
@override
State<LoungePostScreen> createState() => _LoungePostScreenState();
}
class _LoungePostScreenState extends State<LoungePostScreen> {
late LoungePost _post = widget.post;
List<LoungeComment>? _comments;
/// 번역할 댓글. 없으면 번역 댓글은 글 본문의 번역이다(🟡 Q-TR-07 권고 A안).
TranslationReplyTarget? _replyTo;
TranslationRepository? get _translation =>
translationSurfaces.contains(TranslationSurface.lounge)
? widget.translation ?? TranslationScope.maybeOf(context)
: null;
String get _root => 'posts/${_post.id}';
@override
void initState() {
super.initState();
_loadComments();
}
Future<void> _loadComments() async {
try {
final list = await widget.repository.comments(_post.id);
if (mounted) setState(() => _comments = list);
} catch (e) {
if (mounted) {
setState(() => _comments = const []);
_toast(context, loungeError(e));
}
}
}
void _update(LoungePost post) {
setState(() => _post = post);
widget.onChanged?.call(post);
}
Future<void> _run(Future<void> Function() action) async {
try {
await action();
} catch (e) {
if (mounted) _toast(context, loungeError(e));
}
}
Future<void> _like() =>
_run(() async => _update(await widget.repository.toggleLike(_post)));
Future<bool> _send(String body) async {
try {
final c = await widget.repository.addComment(_post.id, body);
if (!mounted) return true;
setState(() => _comments = [...?_comments, c]);
_update(_post.copyWith(comments: _post.comments + 1));
return true;
} catch (e) {
if (mounted) _toast(context, loungeError(e));
return false;
}
}
Future<void> _deleteComment(LoungeComment c) async {
if (!await confirmOverlay(context, context.l10n.commentDeleteConfirm))
return;
await _run(() async {
await widget.repository.deleteComment(_post.id, c.id);
setState(() => _comments = [...?_comments]..remove(c));
_update(
_post.copyWith(comments: _post.comments > 0 ? _post.comments - 1 : 0),
);
});
}
@override
Widget build(BuildContext context) {
final post = _post;
final comments = _comments;
final translation = _translation;
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
tooltip: context.l10n.commonShare,
onPressed: () => _run(() => shareLoungePost(post)),
icon: const ImageIcon(AppIcons.share, size: 24),
),
PopupMenuButton<String>(
tooltip: context.l10n.commonMore,
icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24),
onSelected: (v) => _moderate(context, v),
itemBuilder: (_) => post.isMine
? [
PopupMenuItem(
value: 'edit',
child: Text(context.l10n.commonEditAction),
),
PopupMenuItem(
value: 'delete',
child: Text(context.l10n.commonDeleteAction),
),
]
: [
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
PopupMenuItem(
value: 'block',
child: Text(context.l10n.commonBlockAction),
),
],
),
],
),
body: Column(
children: [
Expanded(
// 시안 `Frame 4241` — 검정 바탕 위에 글 · 댓글 두 판을 8 간격으로 쌓는다.
child: ColoredBox(
color: Colors.black,
child: ListView(
padding: EdgeInsets.zero,
children: [
ColoredBox(
color: AppColors.cosmicVoid,
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoungeAuthorRow(post: post),
const SizedBox(height: 24),
Text(
loungePostText(post),
style: loungeText(16, AppColors.onCosmic),
),
for (final (i, src) in post.images.indexed) ...[
const SizedBox(height: 12),
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
fit: StackFit.expand,
children: [
Image(
image: loungeImage(src),
fit: BoxFit.cover,
),
// 시안 `244:22509` — 영상 길이 「00:36」 15/500 흰색, 오른쪽 아래 10.
if (i == 0 && post.videoDuration != null)
Positioned(
right: 10,
bottom: 10,
child: Text(
_mmss(post.videoDuration!),
style: loungeText(
15,
AppColors.onCosmic,
).copyWith(height: 1),
),
),
],
),
),
),
],
const SizedBox(height: AppSpace.s4),
LoungeCounts(
post: post,
icon: 18,
color: const Color(0xFFD5D7D9),
onLike: _like,
),
],
),
),
),
const SizedBox(height: 8),
if (comments != null)
switch (translation) {
final t? => TranslationThreads(
repository: t,
rootPath: _root,
builder: (_, byTarget) =>
_commentList(comments, t, byTarget),
),
null => _commentList(comments, null, const {}),
},
],
),
),
),
CommentInputBar(
hint: context.l10n.commentHint,
onComment: _send,
// 규칙의 댓글 상한과 같은 수.
maxLength: 1000,
translation: translation,
rootPath: _root,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
);
}
/// 댓글 판 — 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에, 글 본문의 번역 댓글은
/// 댓글 뒤에 붙인다. 둘 다 없으면 시안의 빈 상태.
Widget _commentList(
List<LoungeComment> comments,
TranslationRepository? t,
Map<String, List<TranslationEntry>> byTarget,
) {
final ownTranslations = byTarget[_root] ?? const [];
if (comments.isEmpty && ownTranslations.isEmpty) {
return ColoredBox(
color: AppColors.cosmicVoid,
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Image.asset(
'assets/figma/icon-message-circle.png',
width: 48,
height: 48,
color: const Color(0xFF4F4F4F),
),
const SizedBox(height: AppSpace.s4),
Text(
context.l10n.commentFirst,
style: loungeText(12, AppColors.onCosmicVariant),
),
],
),
),
);
}
const gap = EdgeInsets.fromLTRB(16, 0, 16, 12);
return ColoredBox(
color: AppColors.cosmicVoid,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final c in comments) ...[
_CommentRow(
comment: c,
onDelete: c.isMine ? () => _deleteComment(c) : null,
onTranslate: t == null
? null
: () => setState(
() => _replyTo = (
path: '$_root/comments/${c.id}',
name: c.authorName,
),
),
),
for (final e in byTarget['$_root/comments/${c.id}'] ?? const [])
Padding(
padding: gap,
child: TranslationCommentCard(
entry: e,
repository: t!,
reply: true,
),
),
],
for (final e in ownTranslations)
Padding(
padding: gap,
child: TranslationCommentCard(entry: e, repository: t!),
),
],
),
);
}
Future<void> _moderate(BuildContext context, String action) async {
final post = _post;
switch (action) {
case 'edit':
final updated = await Navigator.of(context, rootNavigator: true)
.push<LoungePost>(
MaterialPageRoute(
builder: (_) => LoungeComposeScreen(
initialBody: post.body,
allowPhotos: false,
onSubmit: (d) => widget.repository.updatePost(post, d.body),
),
),
);
if (updated != null && mounted) _update(updated);
case 'delete':
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm))
return;
await _run(() async {
await widget.repository.deletePost(post);
widget.onChanged?.call(null);
if (mounted) Navigator.pop(this.context);
});
case 'report':
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(
authorName: post.authorName,
title: loungePostText(post),
),
),
),
);
if (reason == null) return;
await _run(() async {
await widget.repository.reportPost(post, reason);
if (mounted) _toast(this.context, context.l10n.reportReceived);
});
case 'block':
if (!await confirmBlock(context, post.authorName)) return;
await _run(() async {
await widget.repository.blockAuthor(post);
widget.onChanged?.call(null);
// 명세 — 차단 처리 후 목록으로 돌아간다.
if (mounted) Navigator.pop(this.context);
});
}
}
}
/// 댓글 한 줄 — 닉네임 · 작성 시각 · 본문. 내 댓글에만 삭제가 붙는다.
/// [onTranslate] 는 이 댓글을 골라 번역 댓글을 다는 지구본(시안 라운지 변형 `252:163306`).
class _CommentRow extends StatelessWidget {
const _CommentRow({required this.comment, this.onDelete, this.onTranslate});
final LoungeComment comment;
final VoidCallback? onDelete;
final VoidCallback? onTranslate;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${comment.authorName} · ${relativeTime(comment.createdAt)}',
style: loungeText(12, AppColors.onCosmicMuted),
),
const SizedBox(height: 4),
Text(comment.body, style: loungeText(14, AppColors.onCosmic)),
],
),
),
if (onTranslate != null)
IconButton(
tooltip: context.l10n.translationWrite,
visualDensity: VisualDensity.compact,
onPressed: onTranslate,
icon: const ImageIcon(
AppIcons.globe,
size: 16,
color: AppColors.onCosmicMuted,
),
),
if (onDelete != null)
TextButton(
onPressed: onDelete,
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmicMuted,
),
child: Text(context.l10n.commonDelete),
),
],
),
);
}
/// 글 쓰기 — 시안 `244:21746`. 제목 칸 없이 본문 한 칸(설계서 §2.6 posts 에 제목 없음).
///
/// [onSubmit] 이 저장을 맡는다. 실패하면 이 화면에 남아 사유를 띄우므로 쓴 글이
/// 날아가지 않는다. 성공하면 [onSubmit] 이 돌려준 글을 들고 닫힌다.
class LoungeComposeScreen extends StatefulWidget {
const LoungeComposeScreen({
super.key,
this.initialBody = '',
this.allowPhotos = true,
this.picker = pickPhotoWithPlugin,
this.onSubmit,
});
final String initialBody;
/// 수정 화면은 사진을 바꾸지 않는다 — 본문만 고친다(규칙상 `imageUrls` 도
/// 고칠 수 있으나 업로드 · 삭제 동선이 한 벌 더 필요해진다).
final bool allowPhotos;
final PhotoPicker picker;
final Future<LoungePost> Function(PostDraft draft)? onSubmit;
@override
State<LoungeComposeScreen> createState() => _LoungeComposeScreenState();
}
class _LoungeComposeScreenState extends State<LoungeComposeScreen> {
late final _body = TextEditingController(text: widget.initialBody);
final _photos = <PickedPhoto>[];
bool _saving = false;
bool get _ready => _body.text.trim().isNotEmpty && !_saving;
@override
void initState() {
super.initState();
_body.addListener(() => setState(() {}));
}
@override
void dispose() {
_body.dispose();
super.dispose();
}
/// 사진 한 장을 골라 1차 검증(B-44 — 5 장 · 10MB · 이미지 형식)을 지난 것만 붙인다.
Future<void> _pick(PhotoSource source) async {
try {
final photo = await widget.picker(source);
if (photo == null) return;
checkLoungePhotos([..._photos, photo]);
setState(() => _photos.add(photo));
} catch (e) {
if (mounted) _toast(context, loungeError(e));
}
}
Future<void> _submit() async {
final draft = PostDraft(
title: '',
body: _body.text.trim(),
photos: List.unmodifiable(_photos),
);
final submit = widget.onSubmit;
if (submit == null) {
Navigator.pop(context);
return;
}
setState(() => _saving = true);
try {
final post = await submit(draft);
if (mounted) Navigator.pop(context, post);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
_toast(context, loungeError(e));
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: Text(
widget.initialBody.isEmpty
? context.l10n.postWrite
: context.l10n.postEdit,
),
actions: [
TextButton(
onPressed: _ready ? _submit : null,
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmic,
disabledForegroundColor: const Color(0xFF474747),
textStyle: loungeText(
14,
AppColors.onCosmic,
).copyWith(fontWeight: FontWeight.w600, height: 1),
minimumSize: const Size(56, 56),
),
child: Text(context.l10n.commonDone),
),
],
),
body: SafeArea(
child: Column(
children: [
if (_saving) const LinearProgressIndicator(),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 22, 16, 16),
child: TextField(
controller: _body,
// 규칙의 글 상한과 같은 수.
maxLength: 5000,
buildCounter: _noCounter,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: loungeText(15, AppColors.onCosmic),
decoration: InputDecoration(
hintText: context.l10n.loungeHint,
hintStyle: loungeText(
15,
const Color(0xFF6B6B6B),
).copyWith(height: 1),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
),
// 고른 사진 — 누르면 뺀다.
if (_photos.isNotEmpty)
SizedBox(
height: 64,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _photos.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) => GestureDetector(
onTap: () => setState(() => _photos.removeAt(i)),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.memory(
_photos[i].bytes,
width: 64,
height: 64,
fit: BoxFit.cover,
semanticLabel: context.l10n.photoRemove('${i + 1}'),
),
),
),
),
),
// 사진 첨부 — 시안 하단 도구 줄(카메라 · 앨범 28, 간격 20).
if (widget.allowPhotos)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
for (final (asset, label, source) in [
(
'assets/figma/icon-compose-camera.png',
context.l10n.camera,
PhotoSource.camera,
),
(
'assets/figma/icon-image.png',
context.l10n.album,
PhotoSource.gallery,
),
]) ...[
InkResponse(
radius: 20,
onTap: _saving ? null : () => _pick(source),
child: Image.asset(
asset,
width: 28,
height: 28,
semanticLabel: label,
),
),
const SizedBox(width: 20),
],
],
),
),
],
),
),
);
}
/// 글자 수 표시는 시안에 없다 — 상한만 건다.
Widget? _noCounter(
BuildContext context, {
required int currentLength,
required bool isFocused,
required int? maxLength,
}) => null;
String _mmss(Duration d) =>
'${d.inMinutes.toString().padLeft(2, '0')}:${(d.inSeconds % 60).toString().padLeft(2, '0')}';
+239
View File
@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_radio.dart';
import '../../core/i18n/app_locale.dart';
/// 신고·차단 — Figma 명세를 공용으로 뺐다.
///
/// 성운 상세 · 행성 상세 · 글 상세 · 팬덤 라운지 네 곳에서 같은 흐름이 반복된다
/// (화면 대조 원장 F3 의 4 · 5 · 8 · 9 · 86번). 화면마다 따로 만들면 문구와
/// 동작이 갈라지므로 하나로 둔다.
///
/// Figma 노드
/// - `373:442007` Galaxy/성운 상세/신고하기 · `373:442417` Galaxy/성운 상세/차단하기
/// - `252:162674` 글 상세/신고하기 · `252:163306` 글 상세/차단하기
///
/// 명세
/// - 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*
/// - 신고 사유 선택 — ***어드민에 등록된 신고 사유 항목*** · *디폴트 미선택 · 단수 선택*
/// - 신고 안내 모달 — *화면 중앙 오버레이 · 취소/확인*
/// - 차단 안내 모달 — *확인 시 차단 처리 후 HOT ARCHIVE 화면 이동*
/// 신고 사유. 명세가 **어드민 등록 항목**이라 하므로 상수로 박지 않고 주입받는다.
/// 정책 설계서 §P-11 — 신고 처리는 전건 `AdminActionLog` 에 남는다.
/// 기본값은 Figma 사본 `373:442007` 의 네 항목이다.
List<String> get defaultReportReasons => [
appL10n.reportDefamation,
appL10n.reportInappropriate,
appL10n.reportSpam,
appL10n.reportPrivacy,
];
/// 신고 사유 → `reports.reasonCode`. 규칙(`firestore.rules` reports)이 이 네 코드만
/// 받는다. 화면 문구가 바뀌어도 Admin 이 집계하는 코드는 그대로다.
Map<String, String> get reportReasonCodes => {
appL10n.reportDefamation: 'defamation',
appL10n.reportInappropriate: 'inappropriate',
appL10n.reportSpam: 'spam',
appL10n.reportPrivacy: 'privacy',
};
/// 신고 대상.
class ModerationTarget {
const ModerationTarget({required this.authorName, required this.title});
final String authorName;
/// 게시물 제목. 프로필을 신고할 때는 닉네임을 그대로 쓴다.
final String title;
}
/// 신고하기 화면.
class ReportScreen extends StatefulWidget {
const ReportScreen({
super.key,
required this.target,
this.reasons,
});
final ModerationTarget target;
final List<String>? reasons;
@override
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
String? _reason;
static TextStyle _t(double size, Color color, {FontWeight w = FontWeight.w600}) =>
TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: w,
height: 1,
letterSpacing: size * -0.025,
color: color,
);
// 시안 `373:442007` — 닫기 X · 「신고하기」 · 오른쪽 「신고」(미선택 #474747),
// 작성자(#ee0c2c)/제목 줄, 「신고 사유」 띠 #232325, 사유 줄 48(라디오 20 · 16/600 · 밑줄 #313131).
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: Text(context.l10n.reportTitle),
actions: [
TextButton(
onPressed: _reason == null ? null : _submit,
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmic,
disabledForegroundColor: const Color(0xFF474747),
textStyle: _t(14, AppColors.onCosmic),
minimumSize: const Size(56, 56),
),
child: Text(context.l10n.reportSubmit),
),
],
),
body: ListView(
padding: EdgeInsets.zero,
children: [
// 신고 정보 영역 — 작성자 닉네임 · 게시물 제목
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
children: [
_info(context.l10n.reportAuthor, widget.target.authorName, const Color(0xFFEE0C2C)),
const SizedBox(height: 16),
const Divider(height: 1, thickness: 1, color: Color(0xFF313131)),
const SizedBox(height: 16),
_info(context.l10n.commonTitle, widget.target.title, const Color(0xFFD1D1D2)),
],
),
),
Container(
color: const Color(0xFF232325),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Text(context.l10n.reportReasonLabel, style: _t(12, const Color(0xFF909091))),
),
// 디폴트 미선택 · 단수 선택.
for (final r in widget.reasons ?? defaultReportReasons)
InkWell(
onTap: () => setState(() => _reason = r),
child: Container(
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFF313131))),
),
child: Row(
children: [
AppRadio(selected: _reason == r),
const SizedBox(width: 8),
Text(r, style: _t(16, const Color(0xFFD1D1D2))),
],
),
),
),
],
),
);
Widget _info(String label, String value, Color color) => Row(
children: [
SizedBox(
width: 36,
child: Text(label, textAlign: TextAlign.center, style: _t(14, const Color(0xFF59595B))),
),
const SizedBox(width: 20),
Expanded(
child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: _t(14, color)),
),
],
);
Future<void> _submit() async {
final ok = await confirmOverlay(
context,
context.l10n.reportConfirm,
);
if (!ok || !mounted) return;
Navigator.pop(context, _reason);
}
}
/// 차단 안내 모달. 확인하면 차단 처리 후 목록으로 돌아간다.
///
/// 문구는 시안 그대로 — 글·회원은 「이 회원의 모든 게시물을 차단할까요?」(`252:163306`),
/// 성운은 「이 성운을 차단할까요?」(`373:442417`). [nickname] 은 부르는 쪽 호환용이다.
Future<bool> confirmBlock(
BuildContext context,
String nickname, {
String? message,
}) => confirmOverlay(context, message ?? context.l10n.blockMemberConfirm);
/// 화면 중앙 오버레이 — 시안 `Frame 1984081031`(256 폭 · #2d2d2d r12 · 문구 14/400 ·
/// 취소/확인 42 · 16/400 #1895ff · 가르는 선 #636463).
Future<bool> confirmOverlay(BuildContext context, String message) async {
const line = BorderSide(color: Color(0xFF636463), width: 0.5);
Widget button(BuildContext c, String label, bool value) => Expanded(
child: InkWell(
onTap: () => Navigator.pop(c, value),
child: SizedBox(
height: 42,
child: Center(
child: Text(
label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w400,
color: Color(0xFF1895FF),
),
),
),
),
),
);
final r = await showDialog<bool>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.6),
builder: (c) => Dialog(
backgroundColor: AppColors.cosmicDialog,
insetPadding: const EdgeInsets.symmetric(horizontal: 59.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.onCosmic,
),
),
),
Container(
decoration: const BoxDecoration(border: Border(top: line)),
child: Row(
children: [
button(c, c.l10n.commonCancel, false),
Container(width: 0.5, height: 42, color: const Color(0xFF636463)),
button(c, c.l10n.commonConfirm, true),
],
),
),
],
),
),
);
return r ?? false;
}
+651
View File
@@ -0,0 +1,651 @@
/// 행성·성운 상세가 그리는 값의 형태.
///
/// 근거는 Figma `03_Wireframe` 의 명세 구간이다.
/// - TOTAL FPI 영역 — *TOTAL FPI 값 · 육각형 그래프 · 실시간 데이터 기준*
/// - FPI 상세 리스트 — *지표별 값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*
/// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출*
library;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto;
import '../../core/i18n/app_locale.dart';
/// FPI 지표 하나. **별첨 B 「FPI 5 축」 확정**(2026-09-09) —
/// `BPM / HEALTH / REFERRAL / CONTRIBUTION / COMMUNITY`.
///
/// 앱 Figma 는 `BMP · STEPS · REFERAL · CONTRIBUTION · COMMUNITY` 로 적었으나
/// 별첨 B 가 상위 기준이므로 화면 표기·**나열 순서**를 별첨 B 로 맞춥니다.
/// [name] 이 그대로 Firestore `fpiAxes` 의 키다(설계서 §4.1).
///
/// **별첨 B 「FPI S/A/B」 = 삭제.** Figma `179:29160` 이 지표 카드에 「내 기여도
/// 등급(S/A/B)」을 두었으나 별첨 B 가 삭제로 확정했으므로 모델에 두지 않는다.
enum FpiMetric {
bpm('BPM'),
health('HEALTH'),
referral('REFERRAL'),
contribution('CONTRIBUTION'),
community('COMMUNITY');
const FpiMetric(this.label);
/// 답변서 v1.1 Q3 확정 표기. 화면에 띄우는 이름이다.
final String label;
}
/// 지표별 값. 명세 — *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*.
class FpiScore {
const FpiScore({
required this.metric,
required this.value,
required this.rank,
required this.trend,
required this.myTopPercent,
});
final FpiMetric metric;
final int value;
/// 전체 순위.
final int rank;
/// 최근 동향. 지난 집계 대비 증감.
final int trend;
/// 내 기여도 상위 %. 작을수록 상위다. **서버 산출값이 아직 없으면 null** —
/// 없는 값을 100%(최하위)로 지어내면 화면이 그것을 사실로 띄운다. 대조표 3-5.
final int? myTopPercent;
/// 육각형 그래프용 0~1 정규화. 지표별 상한이 다르므로 화면이 아니라
/// 여기서 맞춘다.
double normalized(int max) => max == 0 ? 0 : (value / max).clamp(0.0, 1.0);
}
/// 공동 순위 — 정책 원장 **C-19**.
///
/// **실제 산정값(반올림 전)** 내림차순. 값이 같으면 같은 순위이고 다음 순위는
/// 건너뛴다(1·1·3). 동점 내부 배치는 [id] 오름차순으로 고정한다 — 최근 활동
/// 시각으로 차등하지 않는다. 표시가 반올림으로 같아도 실값이 다르면 다른 순위다.
List<(T, int)> rankWithTies<T>(
Iterable<T> items,
num Function(T) value,
String Function(T) id,
) {
final sorted = [...items]..sort((a, b) {
final byValue = value(b).compareTo(value(a));
return byValue != 0 ? byValue : id(a).compareTo(id(b));
});
final out = <(T, int)>[];
for (var i = 0; i < sorted.length; i++) {
final tied = i > 0 && value(sorted[i]) == value(sorted[i - 1]);
out.add((sorted[i], tied ? out[i - 1].$2 : i + 1));
}
return out;
}
/// 랭킹 한 줄. 행성·사용자·국가 랭킹이 모두 같은 모양이라 하나로 둔다
/// (Figma `546:14629` · `546:14674` · `546:14713` — 순위·순위동향·이름·지표 수치).
class FpiRankRow {
const FpiRankRow({
required this.rank,
required this.id,
required this.name,
required this.value,
this.change = 0,
this.isMine = false,
});
/// 행성 랭킹이면 `fandomId`, 사용자 랭킹이면 `uid`, 국가 랭킹이면 국가 코드.
final String id;
final int rank;
final String name;
/// 그 지표의 수치. **지표 카드가 띄우는 값과 같은 원천이어야 한다** —
/// 발주사 검토회신서 4절 「지표 카드와 Ranking 사이 수치·순위 불일치」.
final int value;
/// 순위 동향. 직전 집계 대비 상승분(+)·하락분(-).
final int change;
final bool isMine;
}
/// 국가 코드 → 표시명(ko). 없으면 코드를 그대로 띄운다.
///
/// **표시명을 아는 곳은 여기 한 곳뿐이다.** 서버 집계(`fnRankingCountryAggregate`)는
/// 국가 코드만 적는다 — 표기 언어는 화면의 몫이고, 두 곳에 두면 서버에 없는 나라가
/// 늘 때마다 배포가 필요해진다. 지원 국가 목록·표기 언어는 대조표 7-③ 확인 요청 중.
String countryName(String code) {
final name = appL10n.countryName(code);
return name == '-' ? code : name;
}
/// 랭킹 한 판.
class FpiRanking {
const FpiRanking({required this.rows, this.mine, this.mockNote});
final List<FpiRankRow> rows;
/// 내 줄(행성 랭킹이면 내 행성, 사용자 랭킹이면 나). 목록 밖에 따로 띄운다 —
/// Figma `546:14628` 「소속 행성 지표 랭킹 영역」 · `546:14668` 「나의 랭킹 영역」.
final FpiRankRow? mine;
/// 실데이터가 아닌 경우의 사유. null 이면 실연동이다.
/// 화면이 이 문구를 그대로 배지로 띄운다 — 숨기지 않는다.
final String? mockNote;
bool get isMock => mockNote != null;
}
/// 팬덤 라운지 글 하나.
class LoungePost {
const LoungePost({
required this.id,
required this.authorName,
required this.createdAt,
required this.title,
required this.body,
required this.likes,
required this.comments,
this.images = const [],
this.authorPhoto,
this.videoDuration,
this.authorUid,
this.isMine = false,
this.liked = false,
});
final String id;
/// 작성자 UID. 차단(`users/{uid}/blocks/{authorUid}`)의 대상이다. 표본 글은 null.
final String? authorUid;
/// 내 글인가 — 더보기가 수정 · 삭제를 띄운다.
final bool isMine;
/// 내가 좋아요를 눌렀는가.
final bool liked;
/// 첫 첨부가 영상이면 그 길이(null 이면 사진). 서버 `posts` 에 아직 필드가 없어
/// 서버 글은 늘 null 이다 — 미디어 타입·길이 필드는 후속 과제.
final Duration? videoDuration;
final String authorName;
/// 작성자 프로필 사진. 없으면 회색 원(Figma `Ellipse 3` 기본 채움 #d9d9d9).
final String? authorPhoto;
/// 첨부 사진. URL 또는 앱 에셋 경로. Figma `Frame 1984081008` 계열 격자.
final List<String> images;
/// 명세 — *리스트 생성일 · 최신순 소팅*.
final DateTime createdAt;
final String title;
final String body;
final int likes;
final int comments;
LoungePost copyWith({String? body, int? likes, int? comments, bool? liked}) =>
LoungePost(
id: id,
authorName: authorName,
createdAt: createdAt,
title: title,
body: body ?? this.body,
likes: likes ?? this.likes,
comments: comments ?? this.comments,
images: images,
authorPhoto: authorPhoto,
videoDuration: videoDuration,
authorUid: authorUid,
isMine: isMine,
liked: liked ?? this.liked,
);
}
/// 라운지 댓글 하나 — 설계서 `posts/{postId}/comments/{commentId}`.
class LoungeComment {
const LoungeComment({
required this.id,
required this.authorName,
required this.body,
required this.createdAt,
this.isMine = false,
});
final String id;
final String authorName;
final String body;
final DateTime createdAt;
final bool isMine;
}
/// 라운지 글 한 편에 붙는 사진 상한 — 정책 원장 **B-44** 「이미지 5 장 10MB」.
/// 1 장 상한 · 형식은 [checkProfilePhoto] 와 같다(Storage 규칙도 같은 수).
const int loungeMaxPhotos = 5;
/// 글 사진을 1차 검증한다. 장수 · 용량 · 형식 중 하나라도 넘으면
/// `PhotoRejected` 를 던진다. Storage · Firestore 규칙이 같은 수를 한 번 더 본다.
void checkLoungePhotos(List<PickedPhoto> photos) {
if (photos.length > loungeMaxPhotos) {
throw PhotoRejected(appL10n.loungeMaxPhotos('$loungeMaxPhotos'));
}
photos.forEach(checkProfilePhoto);
}
/// 행성 또는 성운 하나의 상세.
class PlanetDetail {
const PlanetDetail({
required this.fandomId,
required this.name,
this.stage,
required this.isNebula,
required this.totalFpi,
required this.scores,
required this.members,
required this.posts,
this.mockNote,
this.ownerName,
this.ownerPhoto,
this.fpiUpdatedAt,
this.joined = true,
});
/// 내가 이 행성/성운의 멤버인가(`fandoms/{id}/members/{uid}` 존재). 히어로의
/// 「가입중 / 가입하기」 알약이 이 값을 그린다(명세 §2.72 「가입 버튼 - 소속 상태」).
final bool joined;
/// 서버 FPI 집계 시각(`fandoms.fpiUpdatedAt`, 원장 B-15). 없으면 null.
final DateTime? fpiUpdatedAt;
/// 개설자 표시명 · 사진. `fandoms` 문서에 아직 자리가 없어 서버 상세는 null 이고,
/// null 이면 히어로가 개설자 줄을 그리지 않는다(시안 `BTS_Maker` 를 박지 않는다).
final String? ownerName;
final String? ownerPhoto;
/// Firestore `fandoms/{fandomId}` 문서 ID. 지표별 사용자 랭킹이 이 값으로
/// 하위 `members` 를 질의한다.
final String fandomId;
final String name;
/// 성운은 아직 정식 행성이 아니다. 정책 설계서 §P-08.
final bool isNebula;
final int? stage;
/// 명세 — 실시간 데이터 기준. 산출식은 정책 설계서 §P-03(초기 MVP 단순 합산).
final int totalFpi;
final List<FpiScore> scores;
final int members;
final List<LoungePost> posts;
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. 화면이 그대로 배지로
/// 띄운다 — 숨기지 않는다(대조표 3-5).
final String? mockNote;
/// 육각형 그래프 정규화 기준.
int get maxScore =>
scores.map((s) => s.value).fold(0, (a, b) => a > b ? a : b);
}
/// 행성 상세·지표 카드·지표별 랭킹이 **모두 이 하나를 지난다.**
///
/// 발주사 검토회신서 4절이 「지표 카드와 Ranking 상세 사이 수치·순위 불일치,
/// 서로 다른 지표 화면에 동일 Ranking 반복」을 지적했다. 원인은 랭킹 화면이
/// 저장소를 거치지 않고 화면 안의 상수를 그렸기 때문이다. 원천을 하나로 모으면
/// 값이 갈릴 자리가 없어진다.
class HeroDropReceipt {
const HeroDropReceipt({required this.txId, required this.amount});
final String txId;
final int amount;
}
enum PlanetHistoryKind { evolution, hibernation, wakeUp, zap, questReward, companyAdBonus, funding }
class PlanetHistoryEntry {
const PlanetHistoryEntry({required this.id, required this.kind,
required this.occurredAt, this.fromStage, this.toStage,
this.participationConfirmed = false, this.amount});
final String id;
final PlanetHistoryKind kind;
final DateTime occurredAt;
final int? fromStage;
final int? toStage;
final bool participationConfirmed;
final int? amount;
}
class PlanetHistoryPage {
const PlanetHistoryPage({required this.items, this.nextCursor});
final List<PlanetHistoryEntry> items;
final String? nextCursor;
}
abstract interface class PlanetRepository {
Future<PlanetHistoryPage> history({String? cursor, bool personal = false});
Future<HeroDropReceipt> claimHeroDrop(String dropId);
Future<PlanetDetail> load({required bool nebula});
/// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다.
Future<void> join();
/// 명세 — *최초 5개, 스크롤 시 5개씩 호출*.
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
/// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44).
Future<LoungePost> createPost(String body, {List<PickedPhoto> photos = const []});
/// 내 글 본문 수정. 사진은 바꾸지 않는다.
Future<LoungePost> updatePost(LoungePost post, String body);
Future<void> deletePost(LoungePost post);
/// 좋아요 켜고 끄기. 수는 서버 트리거가 다시 센다 — 돌려주는 값은 화면용 예측이다.
Future<LoungePost> toggleLike(LoungePost post);
Future<List<LoungeComment>> comments(String postId);
Future<LoungeComment> addComment(String postId, String body);
Future<void> deleteComment(String postId, String commentId);
/// 신고 접수 — `reports` 에 쌓고 Admin 이 처리한다(B-45).
Future<void> reportPost(LoungePost post, String reason);
/// 작성자 차단 — `users/{uid}/blocks/{authorUid}`. 이후 목록에서 빠진다.
Future<void> blockAuthor(LoungePost post);
/// 지표별 **행성** 랭킹 — Figma `546:14628`·`546:14639`.
///
/// 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성 중 최근 7 일 유효 활동
/// 존재 행성*. 서버가 `fandoms.rankingEligible` 로 표시한다.
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30});
/// 지표별 **사용자** 랭킹 — Figma `546:14668`·`546:14674`.
/// 행성 랭킹의 한 행을 눌러 들어오므로 대상 행성을 받는다.
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
});
/// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」).
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30});
}
/// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든
/// 그 행성의 저장소를 받도록 [Repositories.planet] 을 그대로 넘겨 받는다.
typedef PlanetRepositoryFactory = PlanetRepository Function(String fandomId);
/// 서버가 없을 때의 기본 팩토리. 상수 기본값으로 쓸 수 있게 최상위 함수로 둔다.
/// **받은 ID 를 표본에 넘긴다** — 버리면 어느 행성을 눌러도 같은 상세가 열린다.
PlanetRepository samplePlanetRepository(String fandomId) =>
SamplePlanetRepository(fandomId: fandomId);
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
///
/// 표본이지만 **한 표에서 전부 파생시킨다.** 지표 카드 값·행성 랭킹 순위·사용자
/// 랭킹이 각자 다른 상수를 들고 있으면, 서버가 붙기 전 검수에서 다시 「수치가 안
/// 맞는다」가 된다(발주사 검토회신서 4절).
class SamplePlanetRepository implements PlanetRepository {
@override
Future<void> join() async {}
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []);
@override
Future<HeroDropReceipt> claimHeroDrop(String dropId) =>
Future.error(StateError('실제 지급 서버가 연결되지 않았습니다.'));
/// [fandomId] 는 갤럭시에서 누른 행성이다. 주지 않으면 내 행성으로 본다.
const SamplePlanetRepository({String? fandomId})
: fandomId = fandomId ?? _myFandomId;
/// 상세를 열 행성. **내 행성과 같지 않다** — 갤럭시 HUD 는 남의 행성도 연다.
final String fandomId;
static final _base = DateTime(2026, 8, 31, 14);
/// 내 행성. 「내 기여도」·「내 행성 순위」의 기준이며 [fandomId] 와 다를 수 있다.
static const _myFandomId = 'bts';
/// 내 행성인가. 내 기여도처럼 **나에게만 있는 값**의 게이트다.
/// [fandomId] 가 아니라 [_row] 로 판정한다 — 빈 문자열도 내 행성으로 떨어지므로
/// 「상세는 내 행성, 기여도는 없음」처럼 갈리지 않는다.
bool get _isMine => _row.$1 == _myFandomId;
/// 상세를 그릴 행의 값.
///
/// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서
/// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고,
/// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다.
(String, String, List<int>) get _row =>
_planets.firstWhere((p) => p.$1 == fandomId, orElse: () => _planets.first);
/// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인
/// `bpm · health · referral · contribution · community`.
///
/// **축마다 1위가 다르도록 짰다.** 발주사가 「서로 다른 지표 화면에 동일한
/// Ranking 데이터가 반복된다」를 지적했으므로, 표본에서도 지표를 바꾸면 순서가
/// 바뀌는 것이 눈으로 확인돼야 한다.
/// 각 행의 합계는 갤럭시 맵 표본([SampleGalaxyRepository])의 `fpi` 와 같다.
static const _planets = <(String, String, List<int>)>[
('bts', 'BTS', [82400, 61200, 38900, 46710, 55100]),
('newjeans', 'NewJeans', [91200, 44300, 52800, 42005, 41600]),
('seventeen', 'SEVENTEEN', [58700, 78400, 31200, 22920, 62900]),
('ive', 'IVE', [46100, 39800, 61500, 22640, 28700]),
('aespa', 'aespa', [37400, 52600, 29100, 23350, 33800]),
('lesserafim', 'LE SSERAFIM', [29800, 31500, 44200, 21480, 25100]),
];
/// 행성별 멤버 수. 갤럭시 맵 표본([SampleGalaxyRepository])의 `members` 와 같은
/// 값이다 — 갤럭시 HUD 와 상세가 다른 인원을 띄우면 그것부터 지적받는다.
static const _members = <String, int>{
'bts': 12840,
'newjeans': 11230,
'seventeen': 10450,
'ive': 8320,
'aespa': 7640,
'lesserafim': 6910,
};
/// 내 행성 안의 사용자 표본.
static const _users = <(String, String, List<int>)>[
('u-bunnies', '버니즈', [4820, 2110, 1240, 1530, 980]),
('u-carat', '캐럿', [3960, 3480, 620, 870, 2140]),
('u-me', '나', [2740, 1290, 3180, 4210, 1620]),
('u-moomin', '무민', [1880, 4020, 940, 760, 3050]),
('u-bora', '보라', [1210, 870, 2260, 2980, 1180]),
('u-dive', '다이브짱', [940, 1640, 1720, 1120, 610]),
];
static const _countries = <(String, String, List<int>)>[
('KR', '대한민국', [1284000, 942000, 610000, 998000, 1105000]),
('JP', '일본', [921000, 388000, 742000, 611000, 530000]),
('US', '미국', [733000, 815000, 402000, 455000, 690000]),
('ID', '인도네시아', [512000, 296000, 588000, 372000, 341000]),
('ZZ', '미지정', [84000, 61000, 47000, 55000, 72000]),
];
/// 내 기여 상위 %. 사용자 표본에서 「나」의 순위로 계산한다.
static int _myTopPercent(FpiMetric metric) {
final rows = _rank(_users, metric, myId: 'u-me', limit: _users.length);
final me = rows.mine;
if (me == null) return 100;
return (me.rank * 100 / _users.length).ceil();
}
/// 표를 지표 하나로 내림차순 정렬해 랭킹 한 판을 만든다.
static FpiRanking _rank(
List<(String, String, List<int>)> table,
FpiMetric metric, {
String? myId,
required int limit,
}) {
final axis = metric.index;
final sorted = [...table]..sort((a, b) => b.$3[axis].compareTo(a.$3[axis]));
final rows = <FpiRankRow>[
for (final (i, row) in sorted.indexed)
FpiRankRow(
id: row.$1,
rank: i + 1,
name: row.$2,
value: row.$3[axis],
// 순위 동향도 지표마다 달라야 화면이 같아 보이지 않는다.
change: (axis + i) % 5 - 2,
isMine: row.$1 == myId,
),
];
return FpiRanking(
rows: rows.take(limit).toList(growable: false),
mine: rows.where((r) => r.isMine).firstOrNull,
);
}
@override
Future<PlanetDetail> load({required bool nebula}) async {
// 성운은 아직 랭킹 모집단이 아니다(별첨 B — 정식 행성 중 최근 7 일 유효 활동).
final row = _row;
final axes = nebula ? [for (final v in row.$3) (v / 60).round()] : row.$3;
return PlanetDetail(
fandomId: nebula ? 'new-${row.$1}' : row.$1,
name: nebula ? 'New_${row.$2}' : row.$2,
isNebula: nebula,
// TOTAL FPI = 5 축 단순 합산(별첨 B · 설계서 §4.1, 가중치 초기 1.0).
// 따로 든 상수가 아니라 축의 합이라 육각형·지표 카드와 어긋날 수 없다.
totalFpi: axes.fold(0, (a, b) => a + b),
members: nebula ? 8 : _members[row.$1] ?? 0,
scores: [
for (final metric in FpiMetric.values)
FpiScore(
metric: metric,
value: axes[metric.index],
// 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다.
rank: nebula
? 0
: _rank(_planets, metric, myId: row.$1, limit: _planets.length)
.mine!
.rank,
trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40,
// 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면
// 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null).
myTopPercent: _isMine ? _myTopPercent(metric) : null,
),
],
posts: _posts(0, 5),
);
}
@override
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) async =>
_posts(offset, limit);
/// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다.
static final _comments = <String, List<LoungeComment>>{};
@override
Future<LoungePost> createPost(
String body, {
List<PickedPhoto> photos = const [],
}) async {
checkLoungePhotos(photos);
// 표본은 올릴 곳이 없어 사진을 붙이지 않는다 — 가짜 URL 을 그리면 깨진 칸이 뜬다.
return LoungePost(
id: 'new-${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
createdAt: DateTime.now(),
title: '',
body: body,
likes: 0,
comments: 0,
isMine: true,
);
}
@override
Future<LoungePost> updatePost(LoungePost post, String body) async =>
post.copyWith(body: body);
@override
Future<void> deletePost(LoungePost post) async {}
@override
Future<LoungePost> toggleLike(LoungePost post) async => post.copyWith(
liked: !post.liked,
likes: post.likes + (post.liked ? -1 : 1),
);
@override
Future<List<LoungeComment>> comments(String postId) async => [
...?_comments[postId],
];
@override
Future<LoungeComment> addComment(String postId, String body) async {
final c = LoungeComment(
id: 'c${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
body: body,
createdAt: DateTime.now(),
isMine: true,
);
(_comments[postId] ??= []).add(c);
return c;
}
@override
Future<void> deleteComment(String postId, String commentId) async =>
_comments[postId]?.removeWhere((c) => c.id == commentId);
@override
Future<void> reportPost(LoungePost post, String reason) async {}
@override
Future<void> blockAuthor(LoungePost post) async {}
@override
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) async =>
_rank(_planets, metric, myId: _myFandomId, limit: limit);
/// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면
/// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가
/// 인자로 받으므로(행성 랭킹에서 누른 행) 필드가 아니라 인자로 판정한다.
///
/// ponytail: 행의 이름·수치는 행성이 달라도 같다. 표본을 여섯 벌 더 지어내는
/// 것이라 서버가 붙으면 버릴 값이다. 발주사가 지적한 「같은 Ranking 반복」은
/// 지표(metric)축에서 갈리므로 그쪽 가드(f3 회귀 테스트)로 지킨다.
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) async => _rank(
_users,
metric,
myId: fandomId == _myFandomId ? 'u-me' : null,
limit: limit,
);
@override
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) async =>
// 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713).
_rank(_countries, metric, myId: 'KR', limit: limit);
static List<LoungePost> _posts(int offset, int limit) => [
for (var i = offset; i < offset + limit && i < 18; i++)
LoungePost(
id: 'p$i',
authorName: ['아미보이', '다이브짱', '네누', '버니즈', '캐럿'][i % 5],
createdAt: _base.subtract(Duration(hours: i * 3)),
title: [
'오늘 공방 다녀왔어요',
'이번 앨범 최애 트랙 뭐예요?',
'굿즈 정리했습니다',
'콘서트 좌석 후기',
'데뷔일 프로젝트 모아요',
][i % 5],
body: '팬덤 라운지에 올라온 글입니다. 최신순으로 다섯 개씩 불러옵니다.',
likes: 40 - i,
comments: 12 - (i % 7),
),
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
class PlanetHistoryScreen extends StatefulWidget {
const PlanetHistoryScreen({
super.key,
required this.repository,
required this.fandomName,
});
final PlanetRepository repository;
final String fandomName;
@override
State<PlanetHistoryScreen> createState() => _PlanetHistoryScreenState();
}
class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
final _items = <PlanetHistoryEntry>[];
String? _cursor;
bool _loading = false;
bool _loaded = false;
bool _failed = false;
bool _personal = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (_loading) return;
setState(() {
_loading = true;
_failed = false;
});
try {
final page = await widget.repository.history(cursor: _cursor, personal: _personal);
if (!mounted) return;
setState(() {
final ids = _items.map((item) => item.id).toSet();
_items.addAll(page.items.where((item) => ids.add(item.id)));
_cursor = page.nextCursor;
_loaded = true;
});
} catch (_) {
if (mounted) setState(() => _failed = true);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n.historyTitle)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(widget.fandomName, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
SegmentedButton<bool>(
segments: [
ButtonSegment(value: false, label: Text(context.l10n.homeFandomHistory)),
ButtonSegment(value: true, label: Text(context.l10n.historyMine)),
],
selected: {_personal},
onSelectionChanged: _loading ? null : (selection) {
if (selection.single == _personal) return;
setState(() {
_personal = selection.single;
_items.clear(); _cursor = null; _loaded = false;
});
_load();
},
),
const SizedBox(height: 16),
for (final item in _items)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(switch (item.kind) {
PlanetHistoryKind.evolution => Icons.auto_awesome,
PlanetHistoryKind.hibernation => Icons.bedtime_outlined,
PlanetHistoryKind.wakeUp => Icons.wb_sunny_outlined,
PlanetHistoryKind.zap => Icons.bolt,
PlanetHistoryKind.questReward => Icons.task_alt,
PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline,
PlanetHistoryKind.funding => Icons.favorite_border,
}),
title: Text(switch (item.kind) {
PlanetHistoryKind.evolution =>
context.l10n.historyEvolution(
'${item.fromStage}',
'${item.toStage}',
),
PlanetHistoryKind.hibernation => context.l10n.historyHibernation,
PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp,
PlanetHistoryKind.zap => context.l10n.historyZap,
PlanetHistoryKind.questReward => context.l10n.historyQuest,
PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus,
PlanetHistoryKind.funding => context.l10n.historyFunding,
}),
subtitle: Text(
[
MaterialLocalizations.of(
context,
).formatMediumDate(item.occurredAt.toLocal()),
if (item.amount != null) context.l10n.historyGrowthPop('${item.amount}'),
if (item.participationConfirmed) context.l10n.historyJoined,
].join('\n'),
),
),
if (_loading) const Center(child: CircularProgressIndicator()),
if (_failed) ...[
Text(context.l10n.historyLoadFailed),
TextButton(onPressed: _load, child: Text(context.l10n.commonRetry)),
] else if (_loaded && _items.isEmpty && _cursor == null)
Text(_personal
? context.l10n.historyMineEmpty
: context.l10n.historyFandomEmpty),
if (!_loading && !_failed && _cursor != null)
TextButton(onPressed: _load, child: Text(context.l10n.historyMore)),
],
),
);
}
@@ -0,0 +1,665 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import '../../core/data/backend.dart';
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
import '../my/my_repository_firestore.dart';
import 'moderation.dart' show reportReasonCodes;
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
/// 행성·성운 상세의 실 서버 구현 — 화면 대조 원장 F3.
///
/// **지표 카드·행성 랭킹·사용자 랭킹이 한 원천을 본다.** 발주사 검토회신서 4절이
/// 지적한 「지표 카드와 Ranking 사이 수치·순위 불일치」의 원인이 화면마다 다른
/// 데이터를 들고 있던 것이라, 값이 갈릴 자리를 없앤다.
///
/// | 화면 | 읽는 곳 |
/// |---|---|
/// | TOTAL FPI | `fandoms/{id}.fpiScore` — 서버 가중합 하나 |
/// | 지표 카드 값·전체 순위 | `fandoms/{id}.fpiAxes[축]` · `.fpiRanks[축]` |
/// | 행성 지표 랭킹 | 같은 `fpiAxes[축]` 내림차순 · 순위는 **서버의 `fpiRanks[축]`** |
/// | 사용자 지표 랭킹 | `fandoms/{id}/members/*.fpiAxes[축]` 내림차순 |
/// | 국가 지표 랭킹 | `rankings/country-{축}` 사전 집계 1건 |
///
/// **라운지 목록은 offset 이 아니라 커서로 넘긴다.** Firestore 에는 offset 이 없고,
/// 흉내 내려면 앞 페이지를 매번 다시 읽어야 한다. 인터페이스는 명세대로
/// `offset` 을 받되 내부에서는 마지막 문서를 커서로 들고 간다.
class FirestorePlanetRepository implements PlanetRepository {
FirestorePlanetRepository(this._backend, {required this.fandomId});
final Backend _backend;
final String fandomId;
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
_backend.requireUid;
final result = await _backend.call('fnPlanetHistory', {
'fandomId': fandomId, if (cursor != null) 'cursor': cursor,
if (personal) 'scope': 'personal',
});
final rows = result['items'];
final next = result['nextCursor'];
if (rows is! List || (next != null && (next is! String || next.isEmpty))) {
throw StateError('역사 기록을 확인할 수 없습니다.');
}
final items = <PlanetHistoryEntry>[];
for (final row in rows) {
if (row is! Map) throw StateError('역사 기록을 확인할 수 없습니다.');
final kind = switch (row['kind']) {
'evolution' => PlanetHistoryKind.evolution,
'hibernation' => PlanetHistoryKind.hibernation,
'wake_up' => PlanetHistoryKind.wakeUp,
'zap' => PlanetHistoryKind.zap,
'quest_reward' => PlanetHistoryKind.questReward,
'company_ad_bonus' => PlanetHistoryKind.companyAdBonus,
'funding' => PlanetHistoryKind.funding,
_ => null,
};
final date = row['occurredAt'] is String ? DateTime.tryParse(row['occurredAt']) : null;
final id = row['id'];
final from = row['fromStage'];
final to = row['toStage'];
final amount = row['amount'];
final activity = [PlanetHistoryKind.zap, PlanetHistoryKind.questReward, PlanetHistoryKind.companyAdBonus, PlanetHistoryKind.funding].contains(kind);
if (activity != personal || (activity && (amount is! int || amount <= 0)) || kind == null || date == null || id is! String || id.isEmpty ||
(kind == PlanetHistoryKind.evolution &&
(from is! int || to is! int || from < 0 || to > 6 || to <= from))) {
throw StateError('역사 기록을 확인할 수 없습니다.');
}
items.add(PlanetHistoryEntry(id: id, kind: kind, occurredAt: date,
amount: activity ? amount as int : null,
fromStage: kind == PlanetHistoryKind.evolution ? from as int : null,
toStage: kind == PlanetHistoryKind.evolution ? to as int : null,
participationConfirmed: kind == PlanetHistoryKind.evolution && row['participationConfirmed'] == true,
));
}
return PlanetHistoryPage(items: items, nextCursor: next as String?);
}
@override
Future<HeroDropReceipt> claimHeroDrop(String dropId) async {
_backend.requireUid;
final result = await _backend.call('fnHeroDropClaim', {
'fandomId': fandomId, 'dropId': dropId,
});
final amount = result['claimedPop'];
final txId = result['txId'];
if (amount is! int || amount <= 0 || txId is! String || txId.isEmpty) {
throw StateError('지급 결과를 확인할 수 없습니다.');
}
return HeroDropReceipt(txId: txId, amount: amount);
}
/// 명세 — *최초 5개, 스크롤 시 5개씩 호출*.
static const int pageSize = 5;
DocumentSnapshot<Map<String, dynamic>>? _cursor;
/// 집계 축 키. 지표 이름을 그대로 쓴다 — 설계서 §4.1 의 `fpiAxes` 키와 같다.
/// 두 벌을 두면 집계 축과 화면 표기가 갈린다.
static String _axisKey(FpiMetric metric) => metric.name;
@override
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
@override
Future<PlanetDetail> load({required bool nebula}) async {
final db = _backend.db;
final uid = _backend.requireUid;
final fandomF = db.doc('fandoms/$fandomId').get();
// 최근 동향(최근 변동)은 **일 1 회 Daily FPI Snapshot 대비 현재값**이다.
// 별첨 B — 원천 이벤트 즉시 적재 · FPI/랭킹 1 분 주기 집계 · Daily FPI
// Snapshot 은 일 1 회. 스냅샷 두 건을 비교하면 「어제 대 그저께」가 되어
// 1 분마다 움직이는 지표 카드와 어긋난다.
final snapshotF = db
.collection('fandoms/$fandomId/fpiSnapshots')
.orderBy('createdAt', descending: true)
.limit(1)
.get();
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
_cursor = null;
final postsF = _page(pageSize);
final fandom = (await fandomF).data() ?? const <String, dynamic>{};
final snapshots = (await snapshotF).docs;
final memberSnap = await memberF;
final member = memberSnap.data() ?? const <String, dynamic>{};
final posts = await postsF;
final axes = _map(fandom, 'fpiAxes');
final ranks = _map(fandom, 'fpiRanks');
final myTop = _map(member, 'topPercentByAxis');
// MOCK: `topPercentByAxis` 는 서버 산출값인데 이를 쓰는 집계 함수가 아직
// 없습니다. 필드는 실연동으로 읽고, **값이 없으면 null** 로 둡니다 —
// 100%(최하위)로 채우면 없는 값이 사실처럼 보입니다. 대조표 3-5 참조.
int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num
? myTop.intOr(_axisKey(metric))
: null;
// 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어
// 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다.
final snapshot = snapshots.isEmpty
? const <String, dynamic>{}
: snapshots.first.data();
final dailyAxes = _map(snapshot, 'fpiAxes');
final daily = dailyAxes.isNotEmpty ? dailyAxes : _map(snapshot, 'axes');
final stage = switch (fandom['stage']) {
int n when n >= 0 && n <= 6 => n,
_ => null,
};
return PlanetDetail(
joined: memberSnap.exists,
stage: stage,
fandomId: fandomId,
name: fandom.strOr('name', fandomId),
// TOTAL FPI = 서버가 적어 준 `fpiScore` 하나. 클라이언트가 5축을 다시
// 더하지 않는다 — `/config/policy` 의 가중치가 1.0 을 벗어나는 순간
// 갤럭시 HUD(서버 `fpiScore`)와 이 화면(클라이언트 합)이 갈린다.
// 가중치 초기값은 1.0 이라 지금은 두 값이 같지만, 같아 보이는 것과
// 한 원천을 읽는 것은 다르다(별첨 B · 설계서 §4.1).
totalFpi: fandom.intOr('fpiScore'),
fpiUpdatedAt: switch (fandom['fpiUpdatedAt']) {
Timestamp t => t.toDate(),
_ => null,
},
isNebula: stage == null ? nebula : stage == 0,
members: fandom.intOr('memberCount'),
scores: [
for (final metric in FpiMetric.values)
FpiScore(
metric: metric,
value: axes.intOr(_axisKey(metric)),
rank: ranks.intOr(_axisKey(metric), 0),
// 스냅샷이 아직 없으면 0 — 「오늘 시작 이후 변동 없음」이다.
trend: daily.isEmpty
? 0
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
// 없으면 null — 화면이 숫자 대신 「집계 준비 중」을 띄운다.
myTopPercent: myTopOf(metric),
),
],
posts: posts,
// 한 축이라도 서버 값이 없으면 배지로 알린다. 화면이 이 문구를 그대로
// 띄운다 — 「집계 준비 중」 카드만으로는 이유가 보이지 않는다(대조표 3-5).
mockNote: FpiMetric.values.any((m) => myTopOf(m) == null)
? '「내 기여 상위 %」 집계 함수가 아직 없습니다'
: null,
);
}
@override
Future<FpiRanking> planetRanking({
required FpiMetric metric,
int limit = 30,
}) async {
final axis = _axisKey(metric);
// 모집단 = 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」.
// 최근 7 일 유효 활동 판정은 서버(1 분 집계)가 `rankingEligible` 로 적는다.
// 클라이언트가 `lastActiveAt` 부등호로 거르면 정렬 키와 부등호 필드가 달라
// Firestore 가 질의를 거부한다.
final snap = await _backend.db
.collection('fandoms')
.where('rankingEligible', isEqualTo: true)
.orderBy('fpiAxes.$axis', descending: true)
.limit(limit)
.get();
// **순위는 서버가 적어 준 `fpiRanks[축]` 이다.** 쿼리 결과의 인덱스로 다시
// 매기면 동점에서 두 계산이 갈린다 — 서버는 축 값 내림차순으로, Firestore 는
// 같은 값끼리 문서 ID 순으로 세우기 때문이다. 그 자리가 지표 카드의
// 「전체 n위」와 이 목록의 순위가 어긋나던 곳이다(검토회신서 4절).
// 서버 값이 아직 없는 문서만 쿼리 인덱스로 메운다.
// 서버 값이 없을 때의 메움도 1·1·3 공동 순위다(C-19). 실값(반올림 전)으로 비교하고
// 동점 내부는 fandomId 오름차순 — Firestore 의 내림차순 정렬은 동점을 ID
// 내림차순으로 세우므로 여기서 다시 세운다.
final rows = <FpiRankRow>[
for (final (doc, rank) in rankWithTies(
snap.docs,
(d) => _raw(d.data(), axis),
(d) => d.id,
))
_planetRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank),
];
final mine = rows.where((r) => r.isMine).firstOrNull;
if (mine != null) return FpiRanking(rows: rows, mine: mine);
// 상위 [limit] 밖이면 내 행성 문서를 한 번 더 읽어 「소속 행성 지표 랭킹
// 영역」(Figma `546:14628`)을 채운다. 사용자 랭킹의 count() 폴백과 같은
// 취지다 — 내 행성이 몇 등이든 그 영역은 떠야 한다.
final me = (await _backend.db.doc('fandoms/$fandomId').get()).data();
if (me == null) return FpiRanking(rows: rows);
return FpiRanking(
rows: rows,
// 서버 순위가 없으면 목록 밖이라는 것만 아는 셈이라 0 으로 둔다.
mine: _planetRow(me, id: fandomId, axis: axis, fallbackRank: 0),
);
}
/// 축의 실제 산정값(반올림 전). 순위 비교에만 쓴다 — 표시는 [SnapshotReader.intOr].
static num _raw(Map<String, dynamic> data, String axis) {
final v = _map(data, 'fpiAxes')[axis];
return v is num ? v : 0;
}
/// 행성 랭킹 한 줄. 목록 안이든 밖이든 **같은 필드에서** 만든다.
FpiRankRow _planetRow(
Map<String, dynamic> data, {
required String id,
required String axis,
required int fallbackRank,
}) {
final rank = _map(data, 'fpiRanks').intOr(axis, fallbackRank);
return FpiRankRow(
id: id,
rank: rank,
name: data.strOr('name', id),
value: _map(data, 'fpiAxes').intOr(axis),
// 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다.
change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank,
isMine: id == fandomId,
);
}
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) async {
final axis = _axisKey(metric);
final uid = _backend.requireUid;
final members = _backend.db.collection('fandoms/$fandomId/members');
final snap =
await members.orderBy('fpiAxes.$axis', descending: true).limit(limit).get();
// 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순.
final rows = <FpiRankRow>[
for (final (doc, rank) in rankWithTies(
snap.docs,
(d) => _raw(d.data(), axis),
(d) => d.id,
))
FpiRankRow(
id: doc.id,
rank: rank,
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
name: publicName(doc.data()['nickname']),
value: _map(doc.data(), 'fpiAxes').intOr(axis),
// MOCK: 멤버의 직전 축별 순위(`rankPrevByAxis`)를 쓰는 서버 집계가
// 아직 없습니다. 필드가 없으면 현재 순위를 그대로 넣어 동향이 0 이 되고,
// 화면은 ▲▼ 를 그리지 않습니다. 대조표 3-5 참조.
change: _map(doc.data(), 'rankPrevByAxis').intOr(axis, rank) - rank,
isMine: doc.id == uid,
),
];
// 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로
// 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다.
final note = snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num)
? null
: '사용자 순위 동향은 아직 집계 전입니다';
final mine = rows.where((r) => r.isMine).firstOrNull;
if (mine != null) {
return FpiRanking(rows: rows, mine: mine, mockNote: note);
}
// 상위 목록 밖이면 내 값보다 큰 멤버 수를 세어 정확한 순위를 낸다.
// Figma `546:14668` 「나의 랭킹 영역」은 내가 몇 등이든 떠야 한다.
final me = (await members.doc(uid).get()).data();
if (me == null) return FpiRanking(rows: rows, mockNote: note);
final myValue = _map(me, 'fpiAxes').intOr(axis);
// 실값보다 큰 멤버 수 + 1 = 공동 순위(C-19). 반올림한 값으로 세면 동률이 생긴다.
final above = await members
.where('fpiAxes.$axis', isGreaterThan: _raw(me, axis))
.count()
.get();
return FpiRanking(
rows: rows,
mockNote: note,
mine: FpiRankRow(
id: uid,
rank: (above.count ?? rows.length) + 1,
name: publicName(me['nickname'], appL10n.commonMe),
value: myValue,
isMine: true,
),
);
}
@override
Future<FpiRanking> countryRanking({
required FpiMetric metric,
int limit = 30,
}) async {
// 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」.
// 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다.
final doc = await _backend.db.doc('rankings/country-${_axisKey(metric)}').get();
final rows = (doc.data() ?? const <String, dynamic>{}).mapsOr('rows');
if (rows.isEmpty) {
return const FpiRanking(
rows: [],
mockNote: '국가별 집계가 아직 없습니다',
);
}
// 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다).
// 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을
// 찾아 올린다.
final me = (await _backend.db.doc('fandoms/$fandomId/members/${_backend.requireUid}')
.get())
.data();
final myCode = (me ?? const <String, dynamic>{}).strOr('countryCode', 'ZZ');
final list = <FpiRankRow>[];
for (final (i, row) in rows.take(limit).indexed) {
final code = row.strOr('code', 'ZZ');
list.add(FpiRankRow(
id: code,
rank: row.intOr('rank', i + 1),
// 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]).
name: countryName(code),
value: row.intOr('value'),
change: row.intOr('change'),
isMine: code == myCode,
));
}
return FpiRanking(
rows: list,
mine: list.where((r) => r.isMine).firstOrNull,
);
}
@override
Future<List<LoungePost>> morePosts({
required int offset,
int limit = pageSize,
}) {
// offset 0 은 「처음부터 다시」라는 뜻이다. 커서를 버리고 첫 장을 읽는다.
if (offset == 0) _cursor = null;
return _page(limit);
}
/// 한 장을 읽되 **차단한 작성자의 글은 뺀다.** 한 장이 통째로 빠지면 화면이
/// 「끝」으로 오인하므로 서버에 글이 남아 있는 동안 다음 장을 더 읽는다.
Future<List<LoungePost>> _page(int limit) async {
final blocked = await _blockedUids();
final out = <QueryDocumentSnapshot<Map<String, dynamic>>>[];
while (out.length < limit) {
final snap = await _pageQuery(limit: limit).get();
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
out.addAll(
snap.docs.where((d) => !blocked.contains(d.data()['authorUid'])),
);
if (snap.docs.length < limit) break;
}
return Future.wait(out.map(_post));
}
Query<Map<String, dynamic>> _pageQuery({int limit = pageSize}) {
var query = _backend.db
.collection('posts')
.where('fandomId', isEqualTo: fandomId)
// 설계서 §2.6 — `visibility` 는 visible | hidden | deleted. 규칙이 새 글을
// `visible` 로만 만들게 하므로 목록도 같은 값을 본다.
.where('visibility', isEqualTo: 'visible')
// 명세 — 최신순 소팅. 인덱스는 firestore.indexes.json 의
// (fandomId, visibility, createdAt desc) 가 받는다.
.orderBy('createdAt', descending: true);
final cursor = _cursor;
// 커서를 먼저 걸고 마지막에 개수를 자른다. 순서를 뒤집으면 앞 5개를 자른
// 결과에 커서를 다시 대는 셈이 되어 다음 장이 비어 나온다.
if (cursor != null) query = query.startAfterDocument(cursor);
return query.limit(limit);
}
static Map<String, dynamic> _map(Map<String, dynamic> data, String key) =>
Map<String, dynamic>.from((data[key] as Map?) ?? const <String, dynamic>{});
/// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다.
Set<String>? _blocked;
Future<Set<String>> _blockedUids() async => _blocked ??= {
for (final d in (await _backend.db
.collection('users/${_backend.requireUid}/blocks')
.get())
.docs)
d.id,
};
Future<LoungePost> _post(DocumentSnapshot<Map<String, dynamic>> doc) async {
final data = doc.data() ?? const <String, dynamic>{};
final uid = _backend.requireUid;
final liked =
(await doc.reference.collection('likes').doc(uid).get()).exists;
final author = data['authorUid'];
final images = [
for (final src in [...?(data['imageUrls'] as List?)?.whereType<String>()])
?await _imageUrl(src),
];
return LoungePost(
id: doc.id,
authorUid: author is String ? author : null,
isMine: author == uid,
liked: liked,
authorName: publicName(data['authorName']),
createdAt: data.dateOr('createdAt', DateTime.now()),
title: data.strOr('title'),
body: data.strOr('body'),
likes: data.intOr('likeCount'),
comments: data.intOr('commentCount'),
images: images,
);
}
/// 글 사진 칸의 값 → 화면이 그릴 URL. 새 글은 Storage **경로**를 담는다(규칙이
/// 칸마다 정확한 경로만 받는다). 예전 글 · 시드의 `http` 주소는 그대로 쓴다.
/// 지워졌거나 읽지 못한 사진은 칸을 뺀다 — 깨진 칸을 그리지 않는다.
///
/// ponytail: 사진마다 URL 조회가 한 번씩 붙는다. 목록이 무거워지면 URL 을
/// 트리거가 글 문서에 비정규화해 적는다.
Future<String?> _imageUrl(String src) async {
if (src.startsWith('http')) return src;
try {
return await _backend.storage.ref(src).getDownloadURL();
} catch (_) {
return null;
}
}
/// 작성자 표시명 = 공개 닉네임(`publicName`, 없으면 「팬」). 공급자 실명
/// (`displayName`)은 쓰지 않는다(M-7). 규칙이 같은 계산으로 대조한다.
Future<String> _myName() async => storedPublicName(
(await _backend.db.doc('users/${_backend.requireUid}').get())
.data()?['nickname'],
);
/// 글 쓰기 — 사진 업로드 → 글 문서 한 건.
///
/// 사진은 `users/{uid}/lounge/` 아래에 올린다. 경로 첫 칸이 UID 여야 Storage
/// 규칙이 본인 쓰기로 받는다. 글 저장이 거부되면(다른 팬덤 · 사칭 등) 올린
/// 사진을 지워 고아 파일을 남기지 않는다.
@override
Future<LoungePost> createPost(
String body, {
List<PickedPhoto> photos = const [],
}) async {
checkLoungePhotos(photos);
final uid = _backend.requireUid;
final ref = _backend.db.collection('posts').doc();
final uploaded = <Reference>[];
final paths = <String>[];
try {
for (final (i, photo) in photos.indexed) {
// 파일 이름 = 글 ID-칸 번호. Storage · Firestore 규칙이 이 모양만 받는다.
final path = 'users/$uid/lounge/${ref.id}-$i';
final file = _backend.storage.ref(path);
uploaded.add(file);
await file.putData(
photo.bytes,
SettableMetadata(contentType: photo.contentType),
);
paths.add(path);
}
} catch (error) {
// 어떤 실패든 올린 것부터 치운다. 절반만 올라간 사진을 남기지 않는다.
await _discard(uploaded);
// Storage 규칙이 용량 · 형식을 서버에서 한 번 더 본다(B-44).
throw PhotoRejected(
error is FirebaseException && error.code == 'unauthorized'
? appL10n.photoRejectedServer
: appL10n.photoUploadFailed,
);
}
try {
await ref.set({
'fandomId': fandomId,
'authorUid': uid,
'authorName': await _myName(),
'body': body,
'imageUrls': paths,
'likeCount': 0,
'commentCount': 0,
'visibility': 'visible',
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
});
} catch (_) {
await _discard(uploaded);
rethrow;
}
return _post(await ref.get());
}
static Future<void> _discard(List<Reference> files) async {
for (final f in files) {
try {
await f.delete();
} on FirebaseException {
// 지우기 실패는 원래 오류를 가리지 않게 삼킨다 — 원인은 앞의 실패다.
}
}
}
@override
Future<LoungePost> updatePost(LoungePost post, String body) async {
await _backend.db.doc('posts/${post.id}').update({
'body': body,
'updatedAt': FieldValue.serverTimestamp(),
});
return post.copyWith(body: body);
}
/// 글 삭제 — 규칙이 작성자 본인만 허용한다. 좋아요 · 댓글 · 사진은
/// 서버 트리거(`fnPostCleanup`)가 지운다.
@override
Future<void> deletePost(LoungePost post) =>
_backend.db.doc('posts/${post.id}').delete();
/// 좋아요 — 문서 ID = uid 로 한 사람 한 번. `likeCount` 는 서버 트리거
/// (`fnPostLikeCount`)가 하위 문서 수를 다시 세어 적는다 — 앱은 쓰지 못한다.
@override
Future<LoungePost> toggleLike(LoungePost post) async {
final like = _backend.db.doc(
'posts/${post.id}/likes/${_backend.requireUid}',
);
if (post.liked) {
await like.delete();
} else {
await like.set({'createdAt': FieldValue.serverTimestamp()});
}
return post.copyWith(
liked: !post.liked,
likes: post.likes + (post.liked ? -1 : 1),
);
}
@override
Future<List<LoungeComment>> comments(String postId) async {
final uid = _backend.requireUid;
final blocked = await _blockedUids();
final snap = await _backend.db
.collection('posts/$postId/comments')
.where('visibility', isEqualTo: 'visible')
.orderBy('createdAt')
.get();
return [
for (final d in snap.docs)
if (!blocked.contains(d.data()['authorUid'])) _comment(d, uid),
];
}
static LoungeComment _comment(
DocumentSnapshot<Map<String, dynamic>> d,
String uid,
) {
final data = d.data() ?? const <String, dynamic>{};
return LoungeComment(
id: d.id,
authorName: publicName(data['authorName']),
body: data.strOr('body'),
createdAt: data.dateOr('createdAt', DateTime.now()),
isMine: data['authorUid'] == uid,
);
}
@override
Future<LoungeComment> addComment(String postId, String body) async {
final uid = _backend.requireUid;
final ref = await _backend.db.collection('posts/$postId/comments').add({
'authorUid': uid,
'authorName': await _myName(),
'body': body,
'visibility': 'visible',
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
});
return _comment(await ref.get(), uid);
}
@override
Future<void> deleteComment(String postId, String commentId) =>
_backend.db.doc('posts/$postId/comments/$commentId').delete();
/// 신고 — 설계서 §2.6 `reports`. 문서 ID 를 「대상 + 신고자」로 고정해 같은
/// 사람이 같은 글을 두 번 세지 못하게 한다(B-45 「신고자 수」 집계의 전제).
/// 두 번째 접수는 규칙이 덮어쓰기를 막아 거부된다.
@override
Future<void> reportPost(LoungePost post, String reason) async {
final uid = _backend.requireUid;
final code = reportReasonCodes[reason];
if (code == null) {
throw BackendException('E-PERM-01', appL10n.reportReasonUnknown);
}
try {
await _backend.db.doc('reports/post-${post.id}-$uid').set({
'reporterUid': uid,
'targetType': 'post',
'targetPath': 'posts/${post.id}',
'reasonCode': code,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
} on FirebaseException catch (error) {
if (error.code == 'permission-denied') {
throw BackendException('E-PERM-01', appL10n.reportAlready);
}
rethrow;
}
}
/// 차단 — MY 의 차단 목록과 같은 경로 · 같은 메서드를 쓴다.
@override
Future<void> blockAuthor(LoungePost post) async {
final target = post.authorUid;
if (target == null) {
throw BackendException('E-PERM-01', appL10n.blockUnavailable);
}
await FirestoreMyRepository(
_backend,
).block(target, nickname: post.authorName);
(await _blockedUids()).add(target);
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/i18n/app_locale.dart';
/// iOS 식 하단 시트 한 줄.
class ActionSheetItem {
const ActionSheetItem(this.label, this.onTap, {this.destructive = true});
final String label;
final VoidCallback onTap;
/// 시안의 신고 · 숨기기는 모두 #ff203b.
final bool destructive;
}
/// 시안 `Frame 1984081055` — #2c2c2e r16 묶음(줄 58, 사이선 #6a6864) + 취소 줄 54,
/// 42dot Sans 20/500. 파괴 동작 #ff203b, 취소 #1895ff.
Future<void> showActionSheet(
BuildContext context,
List<ActionSheetItem> items,
) {
TextStyle style(Color c) => TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: -0.5,
color: c,
);
Widget cell(String label, Color color, double height, VoidCallback onTap) =>
InkWell(
onTap: onTap,
child: SizedBox(
height: height,
width: double.infinity,
child: Center(child: Text(label, style: style(color))),
),
);
return showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.6),
builder: (sheet) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: AppColors.cosmicSheet,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (final (i, item) in items.indexed) ...[
if (i > 0)
const Divider(
height: 1,
thickness: 1,
color: Color(0xFF6A6864),
),
cell(
item.label,
item.destructive
? const Color(0xFFFF203B)
: AppColors.onCosmic,
58,
() {
Navigator.of(sheet).pop();
item.onTap();
},
),
],
],
),
),
const SizedBox(height: 8),
Material(
color: AppColors.cosmicSheet,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: cell(
context.l10n.commonCancel,
const Color(0xFF1895FF),
54,
() => Navigator.of(sheet).pop(),
),
),
],
),
),
),
);
}
@@ -0,0 +1,143 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../planet_data.dart';
/// TOTAL FPI 오각 그래프 — Figma 명세 「TOTAL FPI 영역」 · 시안 `Frame 1984081147`(232×140).
///
/// *데이터 : TOTAL FPI 값 · 육각형 그래프 · 규칙 : 실시간 데이터 기준 노출*
///
/// 시안 — 다각형 114×108(외곽 #2c2c2e 2 + 안쪽 3단 1), 데이터 면 #5bd4de 20% · 선 1,
/// 꼭짓점 6 #64eff9. 축 이름 42dot Sans 10/500 #dde3e4 는 위 · 좌우 · 아래 두 칸에 놓는다.
/// 지표가 다섯이 아니면 축 이름을 꼭짓점 바깥에 방사형으로 놓는다.
class FpiHexagon extends StatelessWidget {
const FpiHexagon({super.key, required this.scores, required this.max});
final List<FpiScore> scores;
final int max;
static const size = Size(232, 140);
@override
Widget build(BuildContext context) => SizedBox.fromSize(
size: size,
child: CustomPaint(
painter: _HexPainter(
scores: scores,
max: max,
labelStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1,
color: Color(0xFFDDE3E4),
),
),
),
);
}
class _HexPainter extends CustomPainter {
_HexPainter({
required this.scores,
required this.max,
required this.labelStyle,
});
final List<FpiScore> scores;
final int max;
final TextStyle labelStyle;
/// 격자 단 비율 — 시안 Polygon 3·4·5(93.9 · 63.9 · 32 / 114).
static const rings = [1.0, 0.82, 0.56, 0.28];
@override
void paint(Canvas canvas, Size size) {
if (scores.isEmpty) return;
final n = scores.length;
// 외접원 반지름 — 오각형 높이 108 = R(1 + cos 36°).
final radius = 108 / (1 + math.cos(math.pi / 5)) * size.height / 140;
final center = Offset(size.width / 2, size.height * 16 / 140 + radius);
Offset at(int i, double r) {
// 12시 방향에서 시작해 시계 방향.
final a = -math.pi / 2 + (2 * math.pi * i) / n;
return center + Offset(math.cos(a) * r, math.sin(a) * r);
}
Path poly(double r) {
final p = Path();
for (var i = 0; i < n; i++) {
final o = at(i, r);
i == 0 ? p.moveTo(o.dx, o.dy) : p.lineTo(o.dx, o.dy);
}
return p..close();
}
for (final (i, k) in rings.indexed) {
canvas.drawPath(
poly(radius * k),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = i == 0 ? 2 : 1
..color = AppColors.cosmicOutline,
);
}
// 데이터 다각형.
final data = Path();
for (var i = 0; i < n; i++) {
final o = at(i, radius * scores[i].normalized(max));
i == 0 ? data.moveTo(o.dx, o.dy) : data.lineTo(o.dx, o.dy);
}
data.close();
const line = Color(0xFF5BD4DE);
canvas
..drawPath(data, Paint()..color = line.withValues(alpha: 0.2))
..drawPath(
data,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = line,
);
// 시안 좌표(232×140 기준) — 위 · 오른쪽 · 오른쪽 아래 · 왼쪽 아래 · 왼쪽.
const anchors = [
Offset(116, 5),
Offset(201, 70),
Offset(179.5, 135),
Offset(59, 135),
Offset(25, 70),
];
final sx = size.width / 232, sy = size.height / 140;
for (var i = 0; i < n; i++) {
canvas.drawCircle(
at(i, radius * scores[i].normalized(max)),
3,
Paint()..color = AppColors.neonCyan,
);
final tp = TextPainter(
text: TextSpan(text: scores[i].metric.label, style: labelStyle),
textDirection: TextDirection.ltr,
)..layout();
final o = n == 5
? Offset(anchors[i].dx * sx, anchors[i].dy * sy)
: at(i, radius + 14);
tp.paint(canvas, o - Offset(tp.width / 2, tp.height / 2));
}
}
@override
bool shouldRepaint(_HexPainter old) => old.scores != scores || old.max != max;
}
/// 그리기 비용 측정용 진입점. 사유는 [galaxyFieldPainterForTest] 와 같다.
@visibleForTesting
CustomPainter fpiHexagonPainterForTest({
required List<FpiScore> scores,
required int max,
TextStyle labelStyle = const TextStyle(fontSize: 10),
}) => _HexPainter(scores: scores, max: max, labelStyle: labelStyle);
@@ -0,0 +1,258 @@
import '../../../core/ui/hero_time_status.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/live_planet.dart';
import '../../../core/data/planet_visual_state.dart';
import '../../home/home_screen.dart' show formatPop;
import '../../../core/i18n/app_locale.dart';
/// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543).
///
/// 우주 배경 사진(`373:441126` IMAGE 채움) 위, 상단 104(상태 표시줄 48 + 내비 56)
/// 아래 영역에 개설자(아바타 26 · ABeeZee 16), 행성 일러스트 200, 규모 칩과 멤버 수,
/// 행성 이름 ABeeZee 46, 「FPI : n | 1인당 화력 : n」, 「가입중」 알약을 25 · 12 간격으로
/// 쌓는다. 뒤로가기 · 더보기는 화면의 앱 바가 그린다(스크롤하면 앱 바만 남는다).
class PlanetDetailHero extends StatelessWidget {
const PlanetDetailHero({
super.key,
required this.name,
required this.members,
required this.totalFpi,
required this.joined,
this.nebula = false,
this.onHeroParticipate,
this.onJoin,
this.onOpenHistory,
this.fandomId = '',
this.watchPlanet = noPlanetWatch,
this.stage,
this.ownerName,
this.ownerPhoto,
this.sizeLabel,
});
final VoidCallback? onOpenHistory;
final String fandomId;
final PlanetWatch watchPlanet;
final int? stage;
final String name;
final int members;
final int totalFpi;
final bool joined;
final VoidCallback? onHeroParticipate;
/// 「가입하기」 알약을 눌렀을 때. 이미 가입했으면 null 이고 알약은 상태만 보인다.
final VoidCallback? onJoin;
/// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다.
final bool nebula;
/// 개설자 표시명. 서버에 읽을 자리가 없으면 null 이고 줄을 그리지 않는다 —
/// 예전에는 시안의 `BTS_Maker` 가 모든 행성에 박혀 있었다(인계 결함).
final String? ownerName;
/// 개설자 사진(URL 또는 에셋). 없으면 회색 원.
final String? ownerPhoto;
/// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다.
final String? sizeLabel;
static const double designHeight = 543;
/// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다.
static const double topBar = 104;
static TextStyle _body(double size, Color color) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: FontWeight.w500,
height: 1,
color: color,
);
@override
Widget build(BuildContext context) {
// 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다.
final perHead = members == 0 ? 0.0 : totalFpi / members;
const info = Color(0xFFDEDEDE);
return Stack(
fit: StackFit.expand,
children: [
LivePlanetLighting(
fandomId: fandomId,
watch: watchPlanet,
child: Image.asset(
'assets/figma/bg-space-detail.png',
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, 0.7, 1],
colors: [
Color(0x00141414),
Color(0x22141414),
AppColors.cosmicVoid,
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, topBar, 16, 0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (ownerName case final owner?) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 13,
backgroundColor: const Color(0xFFD9D9D9),
backgroundImage: switch (ownerPhoto) {
final p? when p.startsWith('http') => NetworkImage(p),
final p? => AssetImage(p),
null => null,
},
),
const SizedBox(width: 9),
Text(
owner,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
fontSize: 16,
height: 1.3,
letterSpacing: -0.4,
color: AppColors.onCosmic,
),
),
],
),
const SizedBox(height: 25),
],
LivePlanet25D(
onOpenHistory: onOpenHistory,
fandomId: fandomId,
watch: watchPlanet,
image: 'assets/figma/planet-detail-hero.png',
size: 200,
glow: AppColors.neonViolet,
stage: stage,
),
HeroTimeStatus(
fandomId: fandomId,
watch: watchPlanet,
onParticipate: onHeroParticipate,
),
const SizedBox(height: 25),
// 규모 칩 + 멤버 수
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!nebula) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0x1A54C07D),
border: Border.all(color: const Color(0xFF54C07D)),
borderRadius: BorderRadius.circular(4),
),
child: Text(
sizeLabel ?? context.l10n.planetSizeMedium,
style: _body(12, const Color(0xFF54C07D)),
),
),
const SizedBox(width: 4),
],
Image.asset(
'assets/figma/icon-person.png',
width: 20,
height: 20,
),
const SizedBox(width: 3),
Text(
formatPop(members),
style: _body(14, AppColors.onCosmic),
),
],
),
const SizedBox(height: 12),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
fontSize: 46,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 12),
if (!nebula) ...[
// 값이 커지면(백만 단위 FPI) 한 줄을 줄여 맞춘다.
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'FPI : ${formatPop(totalFpi)}',
style: _body(14, info),
),
const SizedBox(width: 4),
Text('|', style: _body(14, info)),
const SizedBox(width: 4),
Text(
context.l10n.planetPerHead(perHead.toStringAsFixed(2)),
style: _body(14, info),
),
],
),
),
const SizedBox(height: 12),
],
Semantics(
button: onJoin != null,
child: GestureDetector(
onTap: onJoin,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
joined ? context.l10n.planetJoined : context.l10n.planetJoin,
style: _body(
14,
const Color(0xFFD5D7D9),
).copyWith(letterSpacing: -0.35),
),
),
),
),
],
),
),
),
],
);
}
}