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 createState() => _QuestListScreenState(); } class _QuestListScreenState extends State { final _scroll = ScrollController(); List _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(); } /// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다. late int _pop = widget.myPop; Future _load() async { final List cards; try { cards = await widget.repository.load(); } on Exception { // 실패해도 로딩을 끝낸다 — 끝내지 않으면 탭 전체가 무한 로딩으로 남는다(2026-10-04 실기기). if (!mounted) return; setState(() => _loading = false); ScaffoldMessenger.of(context).showSnackBar(SnackBar( behavior: SnackBarBehavior.floating, content: Text(context.l10n.commonRequestFailed), )); return; } final pop = await widget.repository.popBalance().catchError((Object _) => null); if (!mounted) return; setState(() { _cards = cards; _loading = false; // 저장소는 걸러져 모자란 만큼 이어 읽고, 10장보다 적으면 문서가 끝난 것이다(QuestRepository.load). _end = cards.length < 10; if (pop != null) _pop = pop; }); _fill(); } /// 보이는 카드가 화면을 못 채우면(탭으로 걸러져 비었을 때 포함) 스크롤 없이도 다음 페이지를 읽는다. /// 스크롤 리스너만 두면 걸러진 탭에서 다음 페이지로 갈 방법이 없다. void _fill() => WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _end || _more || _loading) return; if (!_scroll.hasClients || _scroll.position.maxScrollExtent <= 240) _loadMore(); }); Future _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.length < 10; }); _fill(); } List 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 _open(QuestCard card) async { await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => QuestDetailScreen( questId: card.id, repository: widget.repository, ), ), ); if (mounted) await _load(); } Future _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 _report(QuestCard card) async { final reason = await Navigator.of(context, rootNavigator: true).push( 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 _body() { final visible = _visible; final rows = _split ? visible.where((c) => c.author == QuestAuthor.admin).toList() : const []; 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(_pop), 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, // 탭을 바꿀 때마다 다시 읽는다 — 「내 퀘스트」 검토 중 → 반려됨이 재진입 없이 바뀌게(2026-10-06 실기기). onSelect: (tab) { setState(() => _tab = tab); _load(); }, ), ), ), body: _loading ? const Center(child: CircularProgressIndicator()) // 당겨서 새로고침 — 상태(검토 중 · 반려됨)와 헤더 보유 POP 을 다시 읽는다. : RefreshIndicator( onRefresh: _load, child: _visible.isEmpty ? LayoutBuilder( builder: (context, box) => ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox( height: box.maxHeight, child: Center( child: Text( context.l10n.questTabEmpty, style: Theme.of(context).textTheme.bodyMedium, ), ), ), ], ), ) : ListView( controller: _scroll, physics: const AlwaysScrollableScrollPhysics(), // 아래 여백은 「새 퀘스트」 버튼(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 _create() async { final id = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => QuestCreateScreen(repository: widget.repository), ), ); if (id != null && mounted) { // 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8). ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text(context.l10n.questSubmittedForReview), behavior: SnackBarBehavior.floating, ), ); 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 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), ), ), ), ), ), ], ), ); }