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 createState() => _FpiRankingScreenState(); } class _FpiRankingScreenState extends State { late final Future _ranking = widget.repository.planetRanking( metric: widget.metric, ); void _openUsers(FpiRankRow planet) => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( 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 createState() => _FpiUserRankingScreenState(); } class _FpiUserRankingScreenState extends State { FpiRankScope _scope = FpiRankScope.live; late Future _ranking = _load(); Future _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 _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 future; final String myLabel; final String emptyLabel; final ValueChanged? onTapRow; /// 행성 랭킹이면 내 줄 아바타가 행성 그림이다. final bool planets; /// 사용자 랭킹의 범위 제목과 그 선택. final String? scopeLabel; final VoidCallback? onPickScope; @override Widget build(BuildContext context) => FutureBuilder( 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 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), ], ); }