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,388 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import '../planet/moderation.dart';
import 'quest_create_screen.dart';
import 'quest_data.dart';
import 'quest_detail_screen.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
/// 퀘스트 목록 — 화면 대조 원장 F4.
///
/// Figma 노드
/// - `259:46109` 홈/퀘스트 (관리자 퀘스트 리스트)
/// - `264:46867` 홈_퀘스트 (탭 목록)
///
/// 명세 `518:88377` — *디폴트 전체 · 단일 탭 선택 가능 ·*
/// *전체 / 진행중 / 완료 / 내 퀘스트 리스트 전환*
/// 명세 `518:88405` — *최초 10개 노출, 스크롤 시 10개씩 로드*
/// 명세 `518:88416` — *새 퀘스트 플로팅 버튼 · 모든 탭 하단 고정*
class QuestListScreen extends StatefulWidget {
const QuestListScreen({
super.key,
required this.repository,
this.myPop = 0,
});
/// 저장소는 호출부가 준다. 화면이 표본을 기본값으로 만들면 서버가 붙은 뒤에도
/// 표본이 그려지는 경로가 남는다(저장소 선택은 `core/data/repositories.dart`).
final QuestRepository repository;
/// 명세 `518:88371` — 헤더 우측에 보유 POP 을 띄운다.
final int myPop;
@override
State<QuestListScreen> createState() => _QuestListScreenState();
}
class _QuestListScreenState extends State<QuestListScreen> {
final _scroll = ScrollController();
List<QuestCard> _cards = const [];
QuestTab _tab = QuestTab.all;
bool _loading = true;
bool _more = false;
bool _end = false;
@override
void initState() {
super.initState();
_scroll.addListener(() {
if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) {
_loadMore();
}
});
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
final cards = await widget.repository.load();
if (!mounted) return;
setState(() {
_cards = cards;
_loading = false;
_end = cards.length < 10;
});
}
Future<void> _loadMore() async {
if (_more || _end) return;
setState(() => _more = true);
final next = await widget.repository.more(offset: _cards.length);
if (!mounted) return;
setState(() {
_cards = [..._cards, ...next];
_more = false;
_end = next.isEmpty;
});
}
List<QuestCard> get _visible {
// 관리자 퀘스트는 명세대로 완료분을 감춘다 —
// *퀘스트 생성 최신순 소팅 · 진행 완료 퀘스트는 hide*(`518:88395`).
final base = _cards.where(
(c) => !(c.author == QuestAuthor.admin && c.stage == QuestStage.rewarded),
);
return base.where(_tab.accepts).toList();
}
/// 시안 `264:46867` — 「전체」 탭은 관리자 퀘스트를 위 「오늘 꼭 해야하는 퀘스트에요」
/// 줄로, 사용자 퀘스트를 아래 카드로 나눈다. 다른 탭은 카드만 있다.
bool get _split => _tab == QuestTab.all;
Future<void> _open(QuestCard card) async {
await Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => QuestDetailScreen(
questId: card.id,
repository: widget.repository,
),
),
);
if (mounted) await _load();
}
Future<void> _act(QuestCard card) async {
// 사용자 생성 Quest 는 상세에서 링크 · 마감 · 참여 규칙을 본 뒤 참여한다(C-05 · C-06 · C-12).
if (card.stage == QuestStage.mine || card.stage == QuestStage.completed ||
card.author == QuestAuthor.user) {
await _open(card);
return;
}
if (card.stage == QuestStage.notStarted) {
// 회신 대기 유형은 목록에서도 참여를 열지 않는다 — 해석 확인 요청서 8.1.
final blocked = card.type.blockedReason;
if (blocked != null) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(blocked),
behavior: SnackBarBehavior.floating,
));
return;
}
final next = await widget.repository.advance(card.id, QuestStage.inProgress);
if (!mounted) return;
setState(() {
_cards = [
for (final c in _cards)
if (c.id == card.id) c.copyWith(stage: next) else c,
];
});
return;
}
// 진행중 — 명세는 *퀘스트 포기 알림 노출* 이다.
await _open(card);
}
Future<void> _report(QuestCard card) async {
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(authorName: card.title, title: context.l10n.questNamed(card.title)),
),
),
);
if (reason != null && mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(context.l10n.reportReceived),
behavior: SnackBarBehavior.floating,
));
}
}
Widget _tile(QuestCard card) => QuestCardTile(
card: card,
onOpen: () => _open(card),
onAction: () => _act(card),
// 시안은 사용자가 만든 퀘스트 카드에만 ⋮ 를 둔다. 내 퀘스트는 상세로 간다.
onMore: card.author != QuestAuthor.user
? null
: card.stage == QuestStage.mine
? () => _open(card)
: () => _report(card),
);
List<Widget> _body() {
final visible = _visible;
final rows = _split
? visible.where((c) => c.author == QuestAuthor.admin).toList()
: const <QuestCard>[];
final cards = _split
? visible.where((c) => c.author != QuestAuthor.admin).toList()
: visible;
return [
if (rows.isNotEmpty) ...[
Text(
context.l10n.questDailyHelp,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.4,
letterSpacing: -0.35,
color: AppColors.cosmicAccent,
),
),
const SizedBox(height: 6),
Text(
context.l10n.questTodayMust,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
height: 24 / 22,
letterSpacing: -0.55,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: Column(
children: [
for (final (i, card) in rows.indexed)
QuestAdminRow(
card: card,
onOpen: () => _open(card),
divider: i < rows.length - 1,
),
],
),
),
if (cards.isNotEmpty) ...[
const SizedBox(height: 24),
const Divider(height: 1, thickness: 1, color: Color(0xFF242424)),
const SizedBox(height: 24),
],
],
for (final (i, card) in cards.indexed) ...[
if (i > 0) const SizedBox(height: 16),
_tile(card),
],
if (_more)
const Padding(
padding: EdgeInsets.all(AppSpace.s4),
child: Center(child: CircularProgressIndicator()),
),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
// 시안 — 번개 아이콘 + 보유 POP. 민트 10% 채움 · 민트 1px 테두리.
Padding(
padding: const EdgeInsets.only(right: AppSpace.s4),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.cosmicMint.withValues(alpha: 0.10),
border: Border.all(color: AppColors.cosmicMint),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/figma/icon-bolt.png', width: 10, height: 10.4),
const SizedBox(width: 5),
Text(
formatPop(widget.myPop),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: -0.3,
color: AppColors.cosmicMint,
),
),
],
),
),
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(44),
child: QuestTabStrip(
selected: _tab,
onSelect: (tab) => setState(() => _tab = tab),
),
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _visible.isEmpty
? Center(
child: Text(
context.l10n.questTabEmpty,
style: Theme.of(context).textTheme.bodyMedium,
),
)
: ListView(
controller: _scroll,
// 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다.
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16),
children: _body(),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.full),
child: InkWell(
onTap: _create,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/figma/icon-pencil.png', width: 16, height: 16),
const SizedBox(width: 6),
Text(
context.l10n.questNew,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
],
),
),
),
),
);
}
Future<void> _create() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => QuestCreateScreen(repository: widget.repository),
),
);
if (id != null && mounted) await _load();
}
}
/// 목록 탭 — 시안(264:46867)은 칩이 아니라 밑줄 탭이다. 좌우 16 안쪽에 네 칸. 활성 흰색 2px 밑줄,
/// 비활성 글자 #bbbbbb 에 #1a1a1a 밑줄. 단일 선택이라 동시에 둘이 켜지지 않는다.
class QuestTabStrip extends StatelessWidget {
const QuestTabStrip({super.key, required this.selected, required this.onSelect});
final QuestTab selected;
final ValueChanged<QuestTab> onSelect;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final tab in QuestTab.values)
Expanded(
child: InkWell(
onTap: () => onSelect(tab),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected == tab ? AppColors.onCosmic : const Color(0xFF1A1A1A),
width: 2,
),
),
),
child: Text(
tab.label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1,
color: selected == tab ? AppColors.onCosmic : const Color(0xFFBBBBBB),
),
),
),
),
),
],
),
);
}