475 lines
15 KiB
Dart
475 lines
15 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: [
|
|
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
|
|
if (data.refreshMinutes case final minutes?) ...[
|
|
Text(
|
|
[
|
|
if (data.updatedAt case final at?)
|
|
context.l10n.planetLastUpdated(
|
|
'${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}',
|
|
),
|
|
context.l10n.rankRefreshEvery('$minutes'),
|
|
].join(' · '),
|
|
style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500),
|
|
),
|
|
const SizedBox(height: AppSpace.s3),
|
|
],
|
|
if (data.mine case final me?) ...[
|
|
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
|
|
const SizedBox(height: AppSpace.s4),
|
|
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),
|
|
],
|
|
);
|
|
}
|