HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -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')}';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user