Files
fanuzu/app/lib/features/planet/fpi_ranking_screen.dart
T

461 lines
14 KiB
Dart

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.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),
],
);
}