Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림

This commit is contained in:
Sn-Kinos
2026-10-06 03:03:20 +09:00
parent 64825e72b0
commit ecf5998725
77 changed files with 2703 additions and 640 deletions
+196 -78
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/planet_25d.dart' show PlanetArt;
import 'planet_data.dart';
import 'widgets/action_sheet.dart';
import '../../core/i18n/app_locale.dart';
@@ -32,10 +33,38 @@ class FpiRankingScreen extends StatefulWidget {
}
class _FpiRankingScreenState extends State<FpiRankingScreen> {
late final Future<FpiRanking> _ranking = widget.repository.planetRanking(
/// 명세 `546:14639` — 최초 30개, 스크롤 시 10개씩.
int _limit = 30;
late Future<FpiRanking> _ranking = widget.repository.planetRanking(
metric: widget.metric,
limit: _limit,
);
Future<FpiRanking> _load(int limit) =>
widget.repository.planetRanking(metric: widget.metric, limit: limit);
bool _loadingMore = false;
/// 지금 장이 꽉 찼을 때만 10개 더 읽는다(덜 찼으면 끝이다).
Future<void> _next() async {
if (_loadingMore) return;
_loadingMore = true;
try {
final now = await _ranking;
if (now.rows.length < _limit || !mounted) return;
final next = _load(_limit + 10);
setState(() {
_limit += 10;
_ranking = next;
});
await next;
} catch (_) {
// 다음 장 실패는 앞 장을 그대로 둔다.
} finally {
_loadingMore = false;
}
}
void _openUsers(FpiRankRow planet) =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
@@ -57,6 +86,9 @@ class _FpiRankingScreenState extends State<FpiRankingScreen> {
myLabel: context.l10n.rankMyPlanet(widget.metric.label),
emptyLabel: context.l10n.rankPlanetEmpty,
onTapRow: _openUsers,
// 명세 `546:14629` — 소속 행성 랭킹 영역도 「사용자 랭킹 화면 이동」.
onTapMine: _openUsers,
onEnd: _next,
planets: true,
),
);
@@ -102,18 +134,45 @@ class FpiUserRankingScreen extends StatefulWidget {
class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
FpiRankScope _scope = FpiRankScope.live;
late Future<FpiRanking> _ranking = _load();
int _limit = 30;
late Future<FpiRanking> _ranking = _load(_limit);
Future<FpiRanking> _load() => switch (_scope) {
Future<FpiRanking> _load(int limit) => switch (_scope) {
FpiRankScope.live => widget.repository.userRanking(
metric: widget.metric,
fandomId: widget.fandomId,
limit: limit,
),
FpiRankScope.byCountry => widget.repository.countryRanking(
metric: widget.metric,
limit: limit,
),
};
/// 국가별은 명세 `546:14713` 「최초 30개 노출 스크롤시 10개씩」. 실시간 사용자 랭킹은 명세에 수가 없어
/// 같은 방식으로 이어 읽는다.
bool _loadingMore = false;
/// 지금 장이 꽉 찼을 때만 10개 더 읽는다(덜 찼으면 끝이다).
Future<void> _next() async {
if (_loadingMore) return;
_loadingMore = true;
try {
final now = await _ranking;
if (now.rows.length < _limit || !mounted) return;
final next = _load(_limit + 10);
setState(() {
_limit += 10;
_ranking = next;
});
await next;
} catch (_) {
// 다음 장 실패는 앞 장을 그대로 둔다.
} finally {
_loadingMore = false;
}
}
@override
Widget build(BuildContext context) => Scaffold(
// 시안 `373:436695` — 제목 「USER RANKING」, 범위 필터는 본문 「실시간 랭킹 ⌄」.
@@ -130,6 +189,8 @@ class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
emptyLabel: context.l10n.rankEmpty,
scopeLabel: context.l10n.rankScope(_scope.label),
onPickScope: _pickScope,
onEnd: _next,
flags: _scope == FpiRankScope.byCountry,
),
);
@@ -143,7 +204,8 @@ class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
if (next != null && next != _scope && mounted) {
setState(() {
_scope = next;
_ranking = _load();
_limit = 30;
_ranking = _load(_limit);
});
}
}
@@ -162,8 +224,20 @@ class _RankingBody extends StatelessWidget {
this.planets = false,
this.scopeLabel,
this.onPickScope,
this.onTapMine,
this.onEnd,
this.flags = false,
});
/// 내 줄 카드를 눌렀을 때.
final ValueChanged<FpiRankRow>? onTapMine;
/// 목록 끝 가까이 스크롤했을 때 — 다음 10개.
final VoidCallback? onEnd;
/// 국가 랭킹 — 아바타 자리에 국기(명세 `546:14713` 「국기 이미지」).
final bool flags;
final Future<FpiRanking> future;
final String myLabel;
final String emptyLabel;
@@ -180,7 +254,8 @@ class _RankingBody extends StatelessWidget {
Widget build(BuildContext context) => FutureBuilder<FpiRanking>(
future: future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
// 다음 장을 읽는 동안에는 앞 장을 그대로 둔다 — 바퀴로 바꾸면 스크롤 자리가 사라진다.
if (!snap.hasData && snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final data = snap.data;
@@ -189,84 +264,115 @@ class _RankingBody extends StatelessWidget {
}
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(
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.extentAfter < 200) onEnd?.call();
return false;
},
child: 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),
Material(
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,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTapMine == null ? null : () => onTapMine!(me),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: _RankRow(
row: me,
index: -1,
planet: planets,
flag: flags,
mark: false,
),
),
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),
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, planet: planets, flag: flags),
),
),
],
),
);
},
);
}
/// 국가 코드 → 국기 이모지. 미지정(`ZZ`) · 형식 밖은 흰 깃발.
String flagOf(String code) {
final c = code.toUpperCase();
if (c.length != 2 || c == 'ZZ' || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) {
return '\u{1F3F3}';
}
return String.fromCharCodes(c.codeUnits.map((u) => 0x1F1E6 + u - 65));
}
TextStyle _rt(
double size,
Color color,
@@ -302,11 +408,14 @@ const _avatarColors = [
Color(0xFFD2F5B8),
];
Widget _avatar(int index, double size, {bool planet = false}) => planet
? Image.asset(
'assets/figma/planet-detail-hero.png',
width: size,
height: size,
Widget _avatar(int index, double size, {bool planet = false, FpiRankRow? row}) => planet
? SizedBox.square(
dimension: size,
child: PlanetArt(
fallback: 'assets/figma/planet-detail-hero.png',
planetType: row?.planetType,
colorKey: row?.colorKey,
),
)
: Container(
width: size,
@@ -348,12 +457,14 @@ class _RankRow extends StatelessWidget {
required this.row,
required this.index,
this.planet = false,
this.flag = false,
this.mark = true,
});
final FpiRankRow row;
final int index;
final bool planet;
final bool flag;
/// 목록 안 내 행에 「(나)」를 붙인다. 위 내 줄 카드는 시안대로 이름만 둔다.
final bool mark;
@@ -381,7 +492,14 @@ class _RankRow extends StatelessWidget {
// 사본 — 40 프레임 가운데 지름 36 원.
SizedBox.square(
dimension: 40,
child: Center(child: _avatar(index, 36, planet: planet)),
child: Center(
child: flag
? Text(
flagOf(row.id),
style: const TextStyle(fontSize: 28, height: 1),
)
: _avatar(index, 36, planet: planet, row: row),
),
),
const SizedBox(width: 6),
Expanded(