QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user