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(
|
||||
|
||||
@@ -32,6 +32,26 @@ enum FpiMetric {
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 팬덤 규모 — Figma 행성 정보 영역 `544:19765` 「소형 : 1,000명 미만 · 중형 : 50,000명 미만 ·
|
||||
/// 대형 : 50,000명 이상」. 기준은 가입자 수다(행성 상세 · GALAXY 검색 결과가 같이 쓴다).
|
||||
enum FandomSize {
|
||||
small,
|
||||
medium,
|
||||
large;
|
||||
|
||||
static FandomSize of(int members) => members < 1000
|
||||
? small
|
||||
: members < 50000
|
||||
? medium
|
||||
: large;
|
||||
|
||||
String get label => switch (this) {
|
||||
small => appL10n.planetSizeSmall,
|
||||
medium => appL10n.planetSizeMedium,
|
||||
large => appL10n.planetSizeLarge,
|
||||
};
|
||||
}
|
||||
|
||||
/// 지표별 값. 명세 — *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*.
|
||||
class FpiScore {
|
||||
const FpiScore({
|
||||
@@ -93,8 +113,14 @@ class FpiRankRow {
|
||||
required this.value,
|
||||
this.change = 0,
|
||||
this.isMine = false,
|
||||
this.planetType,
|
||||
this.colorKey,
|
||||
});
|
||||
|
||||
/// 행성 랭킹 줄의 행성 그림(Figma `546:14629` 「행성 이미지」) — 생성 때 고른 형태 · 색. 없으면 기본 그림.
|
||||
final String? planetType;
|
||||
final String? colorKey;
|
||||
|
||||
/// 행성 랭킹이면 `fandomId`, 사용자 랭킹이면 `uid`, 국가 랭킹이면 국가 코드.
|
||||
final String id;
|
||||
|
||||
@@ -272,8 +298,13 @@ class PlanetDetail {
|
||||
this.growthPop = 0,
|
||||
this.nebulaTargetPop = 1000,
|
||||
this.nebulaTargetMembers = 10,
|
||||
this.isOwner = false,
|
||||
});
|
||||
|
||||
/// 내가 만든 행성/성운인가(`fandoms.founderUid`). Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우
|
||||
/// hide」 · `543:19721` 「(타인 행성) 신고/차단 · (내 행성) 편집」 — 편집은 C-50 으로 두지 않는다.
|
||||
final bool isOwner;
|
||||
|
||||
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
|
||||
final int nebulaTargetPop;
|
||||
final int nebulaTargetMembers;
|
||||
|
||||
@@ -148,7 +148,8 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: context.l10n.commonMore,
|
||||
onPressed: () => _showMore(context, d.name),
|
||||
onPressed: () =>
|
||||
_showMore(context, d.name, mine: d.isOwner),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.ellipsisVertical,
|
||||
size: 24,
|
||||
@@ -169,6 +170,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
ownerName: d.ownerName,
|
||||
ownerPhoto: d.ownerPhoto,
|
||||
joined: d.joined,
|
||||
isOwner: d.isOwner,
|
||||
onJoin: d.joined ? _leave : _join,
|
||||
onOpenHistory: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -209,7 +211,9 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
},
|
||||
);
|
||||
|
||||
void _showMore(BuildContext context, String name) =>
|
||||
/// Figma `543:19721` — 더보기: (타인 행성) 신고/차단 · (내 행성) 편집. 편집은 행성 사용자 커스텀이라
|
||||
/// 두지 않는다(원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
|
||||
void _showMore(BuildContext context, String name, {required bool mine}) =>
|
||||
showActionSheet(context, [
|
||||
// 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다.
|
||||
ActionSheetItem(context.l10n.planetPassportHistory, () {
|
||||
@@ -222,14 +226,16 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
),
|
||||
);
|
||||
}, destructive: false),
|
||||
ActionSheetItem(
|
||||
context.l10n.reportTitle,
|
||||
() => _moderate(context, 'report', name),
|
||||
),
|
||||
ActionSheetItem(
|
||||
context.l10n.commonBlockAction,
|
||||
() => _moderate(context, 'block', name),
|
||||
),
|
||||
if (!mine) ...[
|
||||
ActionSheetItem(
|
||||
context.l10n.reportTitle,
|
||||
() => _moderate(context, 'report', name),
|
||||
),
|
||||
ActionSheetItem(
|
||||
context.l10n.commonBlockAction,
|
||||
() => _moderate(context, 'block', name),
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
Future<void> _moderate(
|
||||
@@ -459,6 +465,7 @@ class _FpiTab extends StatelessWidget {
|
||||
EclipseBanner(
|
||||
repository: eclipse,
|
||||
fandomId: detail.fandomId,
|
||||
nebula: detail.isNebula,
|
||||
margin: const EdgeInsets.only(bottom: AppSpace.s3),
|
||||
),
|
||||
Container(
|
||||
|
||||
@@ -177,6 +177,12 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
final member = memberSnap.data() ?? const <String, dynamic>{};
|
||||
final posts = await postsF;
|
||||
final targets = nebulaTargetsFrom((await policyF).data());
|
||||
// 생성자 = `founderUid`(성운 생성자 · 탄생 전 탈퇴 시 승계자, C-100). 프로필 · 닉네임은 그 멤버 문서에서
|
||||
// 읽는다(Figma `544:19765` 「생성자 프로필 · 닉네임」). 떠났거나 시드 행성이면 줄을 그리지 않는다.
|
||||
final founderUid = fandom.strOr('founderUid');
|
||||
final founder = founderUid.isEmpty
|
||||
? null
|
||||
: (await db.doc('fandoms/$fandomId/members/$founderUid').get()).data();
|
||||
|
||||
final axes = _map(fandom, 'fpiAxes');
|
||||
final ranks = _map(fandom, 'fpiRanks');
|
||||
@@ -199,6 +205,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
_ => null,
|
||||
};
|
||||
return PlanetDetail(
|
||||
isOwner: founderUid.isNotEmpty && founderUid == uid,
|
||||
ownerName: founder == null ? null : publicName(founder['nickname']),
|
||||
joined: memberSnap.exists,
|
||||
stage: stage,
|
||||
fandomId: fandomId,
|
||||
@@ -325,6 +333,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
// 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다.
|
||||
change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank,
|
||||
isMine: id == fandomId,
|
||||
planetType: data['planetType'] is String ? data['planetType'] as String : null,
|
||||
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,7 +345,11 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
required String fandomId,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final ranking = await _userRanking(metric: metric, fandomId: fandomId, limit: limit);
|
||||
final ranking = await _userRanking(
|
||||
metric: metric,
|
||||
fandomId: fandomId,
|
||||
limit: limit,
|
||||
);
|
||||
final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data();
|
||||
return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../planet_data.dart';
|
||||
|
||||
/// 팬덤 규모 칩 — Figma 「대형 팬덤」 `#AEC054` · 「중형 / 소형 팬덤」 `#54C07D`(바탕 10%, r4).
|
||||
/// 행성 상세 히어로는 12/500 + 1px 테두리, GALAXY 목록은 10/500.
|
||||
class FandomSizeBadge extends StatelessWidget {
|
||||
const FandomSizeBadge({super.key, required this.members, this.large = false});
|
||||
|
||||
final int members;
|
||||
|
||||
/// 행성 상세 히어로 크기(12 · 테두리).
|
||||
final bool large;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = FandomSize.of(members);
|
||||
final color = size == FandomSize.large
|
||||
? const Color(0xFFAEC054)
|
||||
: const Color(0xFF54C07D);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
border: large ? Border.all(color: color) : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
size.label,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: large ? 12 : 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../../core/ui/live_planet.dart';
|
||||
import '../../../core/data/planet_visual_state.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import 'fandom_size_badge.dart';
|
||||
|
||||
/// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543).
|
||||
///
|
||||
@@ -24,15 +25,20 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
this.nebula = false,
|
||||
this.onHeroParticipate,
|
||||
this.onJoin,
|
||||
this.isOwner = false,
|
||||
this.onOpenHistory,
|
||||
this.fandomId = '',
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
this.stage,
|
||||
this.ownerName,
|
||||
this.ownerPhoto,
|
||||
this.sizeLabel,
|
||||
this.onOpenOwner,
|
||||
});
|
||||
|
||||
/// 개설자 줄을 눌렀을 때 — Figma `544:19765` 「프로필 및 닉네임 클릭 시 해당 사용자의 MY로 이동」.
|
||||
/// 타인 프로필 화면(`549:21969`)이 생기면 연결한다(3차 MY). null 이면 누르지 않는다.
|
||||
final VoidCallback? onOpenOwner;
|
||||
|
||||
final VoidCallback? onOpenHistory;
|
||||
final String fandomId;
|
||||
final PlanetWatch watchPlanet;
|
||||
@@ -46,6 +52,9 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
/// 알약을 눌렀을 때 — 「가입하기」면 가입, 「가입중」이면 탈퇴 시트. null 이면 상태만 보인다.
|
||||
final VoidCallback? onJoin;
|
||||
|
||||
/// 내가 만든 행성이면 가입 알약을 숨긴다(Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우 hide」).
|
||||
final bool isOwner;
|
||||
|
||||
/// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다.
|
||||
final bool nebula;
|
||||
|
||||
@@ -56,9 +65,6 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
/// 개설자 사진(URL 또는 에셋). 없으면 회색 원.
|
||||
final String? ownerPhoto;
|
||||
|
||||
/// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다.
|
||||
final String? sizeLabel;
|
||||
|
||||
static const double designHeight = 543;
|
||||
|
||||
/// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다.
|
||||
@@ -74,7 +80,8 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다.
|
||||
// 1인당 화력 — 시안 「1.44」. Figma `544:19765` 정의(「모든 포인트 획득, 소비 활동 및 1인당 활동 값을
|
||||
// 집계한 값」)와 FPI 원천(C-34 활동 기준)이 갈려 해석 확인 중(전수대조 영역A Q-A23). 회신 전 임시로 총 FPI ÷ 멤버 수.
|
||||
final perHead = members == 0 ? 0.0 : totalFpi / members;
|
||||
const info = Color(0xFFDEDEDE);
|
||||
|
||||
@@ -111,31 +118,36 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (ownerName case final owner?) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 13,
|
||||
backgroundColor: const Color(0xFFD9D9D9),
|
||||
backgroundImage: switch (ownerPhoto) {
|
||||
final p? when p.startsWith('http') => NetworkImage(p),
|
||||
final p? => AssetImage(p),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
owner,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.onCosmic,
|
||||
GestureDetector(
|
||||
onTap: onOpenOwner,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 13,
|
||||
backgroundColor: const Color(0xFFD9D9D9),
|
||||
backgroundImage: switch (ownerPhoto) {
|
||||
final p? when p.startsWith('http') => NetworkImage(
|
||||
p,
|
||||
),
|
||||
final p? => AssetImage(p),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
owner,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
],
|
||||
@@ -159,21 +171,8 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!nebula) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A54C07D),
|
||||
border: Border.all(color: const Color(0xFF54C07D)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
sizeLabel ?? context.l10n.planetSizeMedium,
|
||||
style: _body(12, const Color(0xFF54C07D)),
|
||||
),
|
||||
),
|
||||
// 소형 · 중형 · 대형 — 가입자 수 기준(Figma `544:19765`).
|
||||
FandomSizeBadge(members: members, large: true),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Image.asset(
|
||||
@@ -227,31 +226,32 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Semantics(
|
||||
button: onJoin != null,
|
||||
child: GestureDetector(
|
||||
onTap: onJoin,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined
|
||||
? context.l10n.planetJoined
|
||||
: context.l10n.planetJoin,
|
||||
style: _body(
|
||||
14,
|
||||
const Color(0xFFD5D7D9),
|
||||
).copyWith(letterSpacing: -0.35),
|
||||
if (!isOwner)
|
||||
Semantics(
|
||||
button: onJoin != null,
|
||||
child: GestureDetector(
|
||||
onTap: onJoin,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined
|
||||
? context.l10n.planetJoined
|
||||
: context.l10n.planetJoin,
|
||||
style: _body(
|
||||
14,
|
||||
const Color(0xFFD5D7D9),
|
||||
).copyWith(letterSpacing: -0.35),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user