Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user