QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 5de04ac281
commit 18afc9cb98
25 changed files with 9368 additions and 0 deletions
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'campaign_data.dart';
import 'widgets/campaign_rank.dart';
import 'widgets/campaign_style.dart';
/// 기여 랭킹 더보기 — 화면 대조 원장 F5 · Figma `333:70971`.
///
/// 명세 `536:14361` — *내 순위 · 순위 변동값 · 프로필 · 닉네임 · 기여 POP*
/// 명세 `536:14371` — *기여 POP에 따른 랭킹 소팅 · 실시간 데이터 기준*
/// 명세 `536:14382` — *랭킹 필터 바텀시트 — 실시간 · 일간 · 주간*
class CampaignRankingScreen extends StatefulWidget {
CampaignRankingScreen({
super.key,
required this.campaignId,
CampaignRepository? repository,
}) : repository = repository ?? SampleCampaignRepository();
final String campaignId;
final CampaignRepository repository;
@override
State<CampaignRankingScreen> createState() => _CampaignRankingScreenState();
}
class _CampaignRankingScreenState extends State<CampaignRankingScreen> {
List<ContributorRank> _ranks = const [];
RankWindow _window = RankWindow.live;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
final ranks = await widget.repository.ranking(
widget.campaignId,
window: _window,
);
if (!mounted) return;
setState(() {
_ranks = ranks;
_loading = false;
});
}
ContributorRank? get _me {
for (final r in _ranks) {
if (r.isMe) return r;
}
return null;
}
@override
Widget build(BuildContext context) {
final me = _me;
final sorted = [..._ranks]..sort((a, b) => a.rank.compareTo(b.rank));
final top = sorted.take(3).toList();
final rest = sorted.skip(3).toList();
// 시안(333:70971) — 「USER RANKING」 · 나의 기여 랭킹 카드 · 「실시간 랭킹 ⌄」 필터 · 단상 · 목록.
return Scaffold(
appBar: AppBar(title: const Text('USER RANKING')),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 내 순위를 맨 위에 고정한다. 목록을 훑지 않아도 보여야 한다.
if (me != null) ...[
Text(
context.l10n.campaignMyRanking,
style: campaignText(24, 500, AppColors.onCosmic, height: 1),
),
const SizedBox(height: AppSpace.s4),
Container(
decoration: BoxDecoration(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: RankRow(
rank: me,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
const SizedBox(height: AppSpace.s5),
],
CampaignRankHeader(window: _window, onTap: _pickWindow),
const SizedBox(height: AppSpace.s3),
RankPodium(top: top),
const SizedBox(height: AppSpace.s3),
for (final r in rest) RankRow(rank: r),
],
),
);
}
Future<void> _pickWindow() async {
final picked = await showModalBottomSheet<RankWindow>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final w in RankWindow.values)
ListTile(
title: Text(w.label),
trailing: _window == w ? const Icon(Icons.check) : null,
onTap: () => Navigator.of(context).pop(w),
),
ListTile(
title: Text(context.l10n.commonCancel),
onTap: () => Navigator.of(context).pop(),
),
],
),
),
);
if (picked == null || !mounted) return;
setState(() => _window = picked);
await _load();
}
}
/// 「실시간 랭킹 ⌄」 — 18/600 · 아래 꺾쇠 20. 누르면 구간 바텀시트(명세 `536:14382`).
class CampaignRankHeader extends StatelessWidget {
const CampaignRankHeader({
super.key,
required this.window,
required this.onTap,
});
final RankWindow window;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: InkWell(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.campaignWindowRanking(window.label),
style: campaignText(18, 600, AppColors.onCosmic, spacing: 0),
),
const SizedBox(width: 4),
const ImageIcon(
AssetImage('assets/figma/campaign-chevron-down.png'),
size: 20,
color: AppColors.onCosmic,
),
],
),
),
);
}