447 lines
15 KiB
Dart
447 lines
15 KiB
Dart
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';
|
|
import '../../core/ui/bottom_inset.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();
|
|
}
|
|
|
|
/// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다.
|
|
late int _pop = widget.myPop;
|
|
|
|
Future<void> _load() async {
|
|
final List<QuestCard> 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<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.length < 10;
|
|
});
|
|
_fill();
|
|
}
|
|
|
|
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(_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: withBottomInset(context, 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) {
|
|
// 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(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<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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|