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,139 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/i18n/app_locale.dart';
/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`.
///
/// 명세 `567:19531` — *분류 특정 위치를 선택했을 경우 목표 수치(특정 위치) 필드 활성화*.
///
/// **지도를 붙이지 않았다.** 지오펜스 반경(D-08)과 대상 장소 등록이 모두 Admin
/// 운영값이고 아직 확정되지 않았다. 지금 지도 SDK 를 넣으면 최소 지원 OS 가
/// 다시 움직인다(플러그인 선검증 §3.5). **장소 선택 동선은 여기서 완결**되고,
/// 지도 표시는 대상 장소가 확정된 뒤 이 화면 안에서 바뀐다.
class PlacePickerScreen extends StatefulWidget {
const PlacePickerScreen({super.key, this.places = defaultPlaces});
final List<String> places;
/// 어드민에 등록될 장소의 표본. **정책값이 아니다.**
static const defaultPlaces = [
'홍대 팝업스토어',
'HYBE 사옥',
'SM 코엑스 아티움',
'올림픽공원 체조경기장',
'고척 스카이돔',
];
@override
State<PlacePickerScreen> createState() => _PlacePickerScreenState();
}
class _PlacePickerScreenState extends State<PlacePickerScreen> {
String _query = '';
String? _selected;
List<String> get _visible => widget.places
.where((p) => _query.isEmpty || p.contains(_query))
.toList();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(leading: const CloseButton()),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안 `371:356705` 머리 — 안내 문구 + 고른 장소. 지도 자리는 아래 검색 목록이다.
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.placePickPrompt,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: -0.45,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 10),
Text(
_selected ?? '',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.4,
color: Color(0xFFCDCDCD),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: TextField(
decoration: InputDecoration(
labelText: context.l10n.placeSearch,
prefixIcon: Icon(Icons.search),
),
onChanged: (v) => setState(() => _query = v.trim()),
),
),
Expanded(
child: _visible.isEmpty
? Center(
child: Text(
context.l10n.commonNoResults,
style: Theme.of(context).textTheme.bodyMedium,
),
)
// 디폴트 미선택 · 단수 선택.
: RadioGroup<String>(
groupValue: _selected,
onChanged: (v) => setState(() => _selected = v),
child: ListView.builder(
itemCount: _visible.length,
itemBuilder: (context, i) {
final place = _visible[i];
return RadioListTile<String>(
value: place,
title: Text(place),
secondary: const Icon(Icons.place_outlined),
);
},
),
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: _selected == null
? null
: () => Navigator.of(context).pop(_selected),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
backgroundColor: AppColors.cosmicAccent,
foregroundColor: Colors.black,
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 24 / 16,
),
),
child: Text(context.l10n.commonSelectDone),
),
),
),
],
),
);
}
@@ -0,0 +1,361 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../core/data/backend.dart';
import '../../core/monetization/store.dart';
import '../home/home_screen.dart' show formatPop;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/i18n/app_locale.dart';
/// 추가 보상 모달 — Figma `272:245274`(홈_퀘스트/퀘스트 상세) · 명세 `523:18244`.
///
/// true 는 광고 보고 받기, false 는 기본 보상 받기, null 은 닫기다. 별첨 1 ⑥
/// 「No-Force」라 **닫아도 기본 보상은 받는다**(호출부가 null 을 false 로 본다).
///
/// [bonusPop] 은 끝까지 보면 더 받는 기여 POP 안내값([QuestCard.adBonusPop])이다.
Future<bool?> showQuestAdBonus(BuildContext context, {int? bonusPop}) =>
showDialog<bool>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.7),
// 시안은 화면 전체 기준 가운데다(안전 영역 기준이면 7 내려간다).
useSafeArea: false,
builder: (context) => _AdBonusDialog(bonusPop: bonusPop),
);
class _AdBonusDialog extends StatelessWidget {
const _AdBonusDialog({this.bonusPop});
final int? bonusPop;
static const _white = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 24 / 14,
color: AppColors.onCosmic,
);
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppColors.cosmicSurface,
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const ImageIcon(AppIcons.x, size: 24, color: AppColors.onCosmic),
),
),
const SizedBox(height: 24),
Text(
context.l10n.adMoreChance,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 15),
// 추가분 — 운영 Quest 는 기본 보상 × (배수 − 1)(B-03 기본 x2 · 지정 x3), 사용자
// 생성 Quest 는 고정 10 기여 POP(C-02). 실제 지급액은 서버가 원장으로 정한다.
Text(
context.l10n.adWatchPrompt,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 16),
Container(
height: 130,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF2E5B49)),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(
bonusPop == null
? context.l10n.adBonusPlaceholder
: '+${formatPop(bonusPop!)} POP',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w600,
height: 1.4,
color: Color(0xFF67D3A9),
),
),
),
const SizedBox(height: 24),
// 광고 제거 구독(명세 `523:18250` *어드민에 등록된 아이템명 및 가격*) ·
// 파티 팩. 파티 팩은 상품 정의가 없어 시안 자리만 둔다.
Row(
children: [
Expanded(child: _FocusOffer()),
SizedBox(width: 8),
Expanded(
child: _Offer(
title: context.l10n.adPartyPack,
titleColor: Color(0xFFDEAB53),
border: Color(0xFFDEAB53),
label: context.l10n.adPickProduct,
),
),
],
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
backgroundColor: AppColors.cosmicAccent,
foregroundColor: Colors.black,
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 24 / 16,
),
),
child: Text(context.l10n.adWatchBonus),
),
],
),
),
const SizedBox(height: 13),
GestureDetector(
onTap: () => Navigator.of(context).pop(false),
child: Text(
context.l10n.adBasicReward,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 24 / 14,
color: AppColors.onCosmicVariant,
),
),
),
],
),
),
);
}
}
/// Focus(광고 제거 구독 · 원장 B-04) 구입 버튼. 가격은 스토어가 준 현지 가격이다 —
/// 시안의 「₩3,900/월」은 확정가(US$1.99)와 달라 쓰지 않는다(공통 기준 2항).
/// 스토어에 상품이 없으면 「스토어 상품 준비 중」으로 멈춘다.
class _FocusOffer extends StatefulWidget {
const _FocusOffer();
@override
State<_FocusOffer> createState() => _FocusOfferState();
}
class _FocusOfferState extends State<_FocusOffer> {
late final Future<ProductDetails?> _product =
Store.instance?.product(Store.focusMonthly).catchError((_) => null) ??
Future.value();
Future<void> _buy(ProductDetails product) async {
try {
await Store.instance?.buy(product.id);
} on Exception catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(error is BackendException
? error.message
: context.l10n.payOpenFailed),
));
}
}
@override
Widget build(BuildContext context) => FutureBuilder<ProductDetails?>(
future: _product,
builder: (context, snap) {
final product = snap.data;
final offer = _Offer(
title: context.l10n.focusMode,
titleColor: const Color(0xFFB96774),
border: const Color(0xFF59242F),
label: snap.connectionState != ConnectionState.done
? '…'
: product == null
? Store.notReady
: context.l10n.pricePerMonth(product.price),
);
return product == null
? offer
: GestureDetector(onTap: () => _buy(product), child: offer);
},
);
}
class _Offer extends StatelessWidget {
const _Offer({
required this.title,
required this.titleColor,
required this.border,
required this.label,
});
final String title;
final Color titleColor;
final Color border;
final String label;
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minHeight: 56),
padding: const EdgeInsets.symmetric(vertical: 4),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
border: Border.all(color: border),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: _AdBonusDialog._white.copyWith(color: titleColor)),
Text(label, style: _AdBonusDialog._white),
],
),
);
}
/// 광고 전후 대기 화면 — Figma `494:14332`(광고 시청 전) · `482:15084`(광고 시청 후).
///
/// [until] 이 끝나면 스스로 닫힌다. 「건너뛰기」는 기다리지 않고 닫는다.
class QuestAdWarpScreen extends StatefulWidget {
const QuestAdWarpScreen({
super.key,
required String this.message,
this.until,
}) : _after = false;
/// 광고를 찾는 중.
const QuestAdWarpScreen.before({super.key, this.until})
: message = null,
_after = false;
/// 보상을 받는 중.
const QuestAdWarpScreen.after({super.key, this.until})
: message = null,
_after = true;
/// null 이면 [before]/[after] 기본 문구를 지금 언어로 쓴다.
final String? message;
final bool _after;
final Future<Object?>? until;
@override
State<QuestAdWarpScreen> createState() => _QuestAdWarpScreenState();
}
class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
String _message(BuildContext context) =>
widget.message ??
(widget._after ? context.l10n.adWarpAfter : context.l10n.adWarpBefore);
@override
void initState() {
super.initState();
// 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다.
unawaited(widget.until?.catchError((_) => null).whenComplete(_close));
}
/// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도
/// 대기 화면이 그 밑에 남지 않는다.
void _close() {
final route = mounted ? ModalRoute.of(context) : null;
if (route != null && route.isActive) Navigator.of(context).removeRoute(route);
}
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
// 시안의 본문 구간(위 56 머리 · 아래 72 버튼 자리) 가운데에 놓는다.
child: Padding(
padding: const EdgeInsets.only(top: 56, bottom: 72),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212),
const SizedBox(height: 24),
// 시안은 405 폭 한 줄이라 375 화면 밖으로 잘린다 — 좌우 16 안에서 가운데 두 줄로.
// 행간은 한 줄이면 사본 값 1, 두 줄로 넘어갈 때만 1.4.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, box) {
// Text 와 같은 글꼴로 재야 한다(테마 기본 글꼴을 물려받는다).
final style = DefaultTextStyle.of(context).style.merge(
const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
);
final painter = TextPainter(
text: TextSpan(text: _message(context), style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: box.maxWidth);
final wraps = painter.didExceedMaxLines;
painter.dispose();
return Text(
_message(context),
textAlign: TextAlign.center,
style: wraps ? style.copyWith(height: 1.4) : style,
);
},
),
),
const SizedBox(height: 24),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Text(
context.l10n.adSkip,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 24 / 14,
color: AppColors.onCosmicVariant,
),
),
),
],
),
),
),
),
);
}
@@ -0,0 +1,576 @@
import 'package:flutter/material.dart';
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'place_picker_screen.dart';
import 'quest_data.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
/// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`.
///
/// 명세 `523:18152` — *완료 버튼 디폴트 비활성화. 입력 항목이 모두 선택될 경우 활성화 ·*
/// *클릭 시 생성 안내 모달 노출*
/// 명세 `523:18163` — *퀘스트 미리보기 카드 · 입력 상태에 따라 실시간 반영*
/// 명세 `523:18173` — *디폴트 카테고리 항목 미선택 · 단일 선택 가능 ·*
/// *선택한 카테고리에 해당하는 입력필드 노출*
class QuestCreateScreen extends StatefulWidget {
const QuestCreateScreen({super.key, required this.repository});
/// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다.
final QuestRepository repository;
@override
State<QuestCreateScreen> createState() => _QuestCreateScreenState();
}
class _QuestCreateScreenState extends State<QuestCreateScreen> {
final _title = TextEditingController();
final _description = TextEditingController();
final _url = TextEditingController();
final _manualTitle = TextEditingController();
final _reward = TextEditingController();
final _participants = TextEditingController();
/// 이 화면 한 번의 생성 요청 키 — 응답 유실 뒤 다시 눌러도 서버가 한 건으로 묶는다.
final _clientSeq = DateTime.now().microsecondsSinceEpoch.toString();
QuestCatalog? _catalog;
QuestCategory? _category;
QuestActivity? _activity;
List<QuestPlace> _places = const [];
QuestPlace? _place;
DateTime? _endDate;
QuestLink? _link;
bool _checkingLink = false;
bool _saving = false;
@override
void initState() {
super.initState();
for (final c in [_title, _description, _manualTitle, _reward, _participants]) {
// 입력이 바뀔 때마다 미리보기와 완료 버튼 활성화가 함께 움직여야 한다.
c.addListener(() => setState(() {}));
}
// 주소가 바뀌면 이전 미리보기는 더 이상 이 주소의 것이 아니다.
_url.addListener(() {
if (_link != null && _link!.url != _url.text.trim()) setState(() => _link = null);
});
_load();
}
@override
void dispose() {
for (final c in [_title, _description, _url, _manualTitle, _reward, _participants]) {
c.dispose();
}
super.dispose();
}
Future<void> _load() async {
final catalog = await widget.repository.catalog();
if (mounted) setState(() => _catalog = catalog);
}
int _intOf(TextEditingController c) => int.tryParse(c.text.trim()) ?? 0;
bool _needs(String field) => _activity?.fields.contains(field) ?? false;
bool get _paid => !(_activity?.noReward ?? true);
int get _pool => _intOf(_reward) * _intOf(_participants);
/// 완료 버튼 활성화 조건 — 선택한 활동에 필요한 입력만 본다(C-01).
bool get _ready {
if (_activity == null || _title.text.trim().isEmpty) return false;
if (_needs('place') && _place == null) return false;
if (_needs('link')) {
final link = _link;
if (link == null || !(link.ok || (link.failed && _manualTitle.text.trim().isNotEmpty))) {
return false;
}
}
return !_paid || (_intOf(_reward) > 0 && _intOf(_participants) > 0);
}
QuestCard get _preview => QuestCard(
id: 'preview',
title: _title.text.trim().isEmpty
? context.l10n.questPreviewHint
: _title.text.trim(),
categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory,
author: QuestAuthor.user,
stage: QuestStage.mine,
goal: 1,
progress: 0,
rewardPop: _intOf(_reward),
// 시안 — *참여 인원 : 0 / 0*. 분모가 모집 인원이다.
participants: 0,
capacity: _intOf(_participants),
);
/// 카테고리 칩 아이콘 — 시안 `254:164081`. 분류 키(`config/questCatalog`)로 고른다.
static String? _chipIcon(String key) => switch (key) {
'health' => 'assets/figma/quest-pulse.png',
'planet' => 'assets/figma/quest-globe.png',
'community' => 'assets/figma/quest-people.png',
'location' => 'assets/figma/quest-pin.png',
_ => null,
};
static const _fieldBorder = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
borderSide: BorderSide(color: Color(0xFF333333)),
);
InputDecoration _field(String label, {String? helper, String? error, Widget? suffix}) =>
InputDecoration(
labelText: label,
helperText: helper,
errorText: error,
suffixIcon: suffix,
border: _fieldBorder,
enabledBorder: _fieldBorder,
);
static const _sectionStyle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w500,
height: 28 / 20,
letterSpacing: -0.5,
color: AppColors.onCosmic,
);
@override
Widget build(BuildContext context) {
final catalog = _catalog;
final category = _category;
final activity = _activity;
final link = _link;
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: Text(context.l10n.questNew),
actions: [
TextButton(
onPressed: _ready && !_saving ? _confirm : null,
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmic,
disabledForegroundColor: const Color(0xFF474747),
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1,
letterSpacing: -0.35,
),
),
child: Text(context.l10n.commonDone),
),
],
),
body: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 명세 — 입력 상태에 따라 실시간 반영.
QuestCardTile.preview(card: _preview),
const SizedBox(height: 24),
const Divider(height: 1, thickness: 1, color: Color(0xFF242424)),
const SizedBox(height: 24),
Text(context.l10n.questCategory, style: _sectionStyle),
const SizedBox(height: 20),
if (catalog != null && catalog.categories.isEmpty)
Text(
context.l10n.questNoCreatable,
style: const TextStyle(color: AppColors.onCosmicVariant),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final c in catalog?.categories ?? const <QuestCategory>[])
_CategoryChip(
label: c.name,
icon: _chipIcon(c.id),
selected: category?.id == c.id,
onTap: () => setState(() {
_category = c;
// 분류를 바꾸면 활동과 그 활동의 입력을 다시 고른다.
_activity = c.activities.length == 1 ? c.activities.first : null;
_place = null;
_link = null;
}),
),
],
),
// 명세 `523:18182` — *액션 종류(어드민에서 등록된 데이터) · 선택한 카테고리에 따라 노출*.
if (category != null) ...[
const SizedBox(height: 36),
Text(context.l10n.questActivity, style: _sectionStyle),
const SizedBox(height: 20),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final a in category.activities)
_CategoryChip(
label: a.name,
icon: null,
selected: activity?.key == a.key,
onTap: () => setState(() {
_activity = a;
_place = null;
_link = null;
}),
),
],
),
],
// 선택한 활동에 필요한 입력만(C-01).
if (activity != null) ...[
const SizedBox(height: 36),
TextField(controller: _title, decoration: _field(context.l10n.questName)),
const SizedBox(height: AppSpace.s3),
TextField(
controller: _description,
maxLines: 3,
minLines: 1,
decoration: _field(context.l10n.questDescriptionOptional),
),
const SizedBox(height: AppSpace.s3),
if (_needs('link')) ...[
TextField(
controller: _url,
keyboardType: TextInputType.url,
decoration: _field(
context.l10n.questLinkUrl,
error: link?.status == 'invalid' ? context.l10n.questLinkInvalid : null,
suffix: IconButton(
tooltip: context.l10n.questLinkCheck,
icon: _checkingLink
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search),
onPressed: _checkingLink ? null : _checkLink,
),
),
onSubmitted: (_) => _checkLink(),
),
if (link != null && link.ok) ...[
const SizedBox(height: AppSpace.s3),
QuestLinkCard(link: link),
],
if (link != null && link.failed) ...[
const SizedBox(height: AppSpace.s3),
TextField(
controller: _manualTitle,
decoration: _field(
context.l10n.questLinkTitle,
helper: context.l10n.questLinkPreviewFailed,
),
),
],
const SizedBox(height: AppSpace.s3),
],
if (_needs('place')) ...[
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.place_outlined),
title: Text(_place?.name ?? context.l10n.questPickPlace),
trailing: const Icon(Icons.chevron_right),
onTap: _pickPlace,
),
const SizedBox(height: AppSpace.s3),
],
// 기간 — 기본 공개 + 24h, 종료일은 생성자 시간대의 23:59:59(C-06).
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined),
title: Text(context.l10n.questEndDate),
subtitle: Text(
_endDate == null
? context.l10n.questEndDefault
: context.l10n.questEndDateValue(_dateLabel(_endDate!)),
),
trailing: const Icon(Icons.chevron_right),
onTap: _pickEndDate,
),
const SizedBox(height: AppSpace.s3),
if (_paid) ...[
Row(
children: [
Expanded(
child: TextField(
controller: _participants,
keyboardType: TextInputType.number,
decoration: _field(context.l10n.questParticipants),
),
),
const SizedBox(width: AppSpace.s3),
Expanded(
child: TextField(
controller: _reward,
keyboardType: TextInputType.number,
decoration: _field(context.l10n.questRewardPop),
),
),
],
),
const SizedBox(height: AppSpace.s2),
// 명세 `523:18194` — *참여 인원 및 완료 보상 POP 기준 계산* · B-30 수수료.
Text(
context.l10n.questPoolHelper(
formatPop(_pool),
formatPop(catalog?.feeOf(_pool) ?? 0),
),
style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant),
),
] else
Text(
context.l10n.questNoReward,
style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant),
),
],
],
),
);
}
static String _dateLabel(DateTime d) =>
'${d.year}.${d.month.toString().padLeft(2, '0')}.${d.day.toString().padLeft(2, '0')}';
Future<void> _checkLink() async {
final url = _url.text.trim();
if (url.isEmpty || _checkingLink) return;
setState(() => _checkingLink = true);
QuestLink link;
try {
link = await widget.repository.previewLink(url);
} on BackendException {
// 조회 호출 자체가 실패해도 주소가 틀렸다는 뜻은 아니다 — 기본 카드 경로(C-05).
link = QuestLink(url: url, status: 'preview_failed');
}
if (!mounted) return;
setState(() {
_checkingLink = false;
_link = _url.text.trim() == url ? link : null;
});
}
Future<void> _pickPlace() async {
if (_places.isEmpty) {
final places = await widget.repository.places();
if (!mounted) return;
_places = places;
}
final name = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => PlacePickerScreen(places: [for (final p in _places) p.name]),
),
);
if (name == null || !mounted) return;
setState(() => _place = _places.firstWhere((p) => p.name == name));
}
/// 공개일을 1일째로 최대 [QuestCatalog.maxDays] 일째까지. 남은 1시간 미만 판정은 서버가 한다.
Future<void> _pickEndDate() async {
// 오늘은 서버 시각의 현지 날짜 — 단말 시계를 돌려도 선택 범위가 늘지 않는다(G-04).
final today = DateUtils.dateOnly(ServerClock.now());
final picked = await showDatePicker(
context: context,
initialDate: _endDate ?? today,
firstDate: today,
lastDate: today.add(Duration(days: (_catalog?.maxDays ?? 30) - 1)),
);
if (picked != null && mounted) setState(() => _endDate = picked);
}
/// 명세 — *완료 버튼 클릭 시, 생성 안내 모달 노출*.
Future<void> _confirm() async {
final pool = _paid ? _pool : 0;
final fee = _catalog?.feeOf(pool) ?? 0;
final go = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questCreateConfirm),
content: Text(
context.l10n.questCreateCost(
_preview.title,
formatPop(_intOf(_reward)),
formatPop(_intOf(_participants)),
formatPop(pool),
formatPop(fee),
formatPop(pool + fee),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonKeepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questCreate),
),
],
),
);
if (go != true || !mounted) return;
setState(() => _saving = true);
final String id;
try {
id = await widget.repository.create(
QuestDraft(
clientSeq: _clientSeq,
activityKey: _activity!.key,
title: _title.text.trim(),
description: _description.text.trim(),
rewardPop: _paid ? _intOf(_reward) : 0,
participants: _paid ? _intOf(_participants) : 0,
endDate: _endDate,
url: _needs('link') ? _link?.url : null,
manualTitle: _needs('link') && (_link?.failed ?? false) ? _manualTitle.text.trim() : null,
placeId: _needs('place') ? _place?.id : null,
),
);
} on BackendException catch (error) {
// 잔액 부족 · 자격 · 기간 거부는 서버 문구를 그대로 띄운다. 삼키면 「아무 일도 없다」로 보인다.
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(error.message),
behavior: SnackBarBehavior.floating,
));
return;
}
if (mounted) Navigator.of(context).pop(id);
}
}
/// 링크 카드 — 서버가 조회한 **원본** 제목 · 썸네일 · 서비스명(C-05 · R10). 작성자 설명과 섞지 않는다.
class QuestLinkCard extends StatelessWidget {
const QuestLinkCard({super.key, required this.link});
final QuestLink link;
@override
Widget build(BuildContext context) {
final thumb = link.thumbnail;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
children: [
if (thumb != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
thumb,
width: 96,
height: 54,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox(width: 96, height: 54),
),
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (link.provider != null)
Text(
link.provider!,
style: const TextStyle(fontSize: 12, color: AppColors.onCosmicVariant),
),
Text(
link.title.isEmpty ? link.url : link.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.onCosmic,
),
),
],
),
),
],
),
);
}
}
/// 카테고리 칩 — #1a1a1a · #232324 테두리 · 높이 42 알약. 고르면 흰 바탕 검은 글자.
class _CategoryChip extends StatelessWidget {
const _CategoryChip({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final String? icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fg = selected ? Colors.black : AppColors.onCosmic;
return Semantics(
button: true,
selected: selected,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
border: Border.all(color: const Color(0xFF232324)),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
ImageIcon(AssetImage(icon!), size: 20, color: fg),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
letterSpacing: -0.5,
color: fg,
),
),
],
),
),
),
);
}
}
+771
View File
@@ -0,0 +1,771 @@
/// 퀘스트가 그리는 값의 형태 — 화면 대조 원장 F4.
///
/// 근거는 Figma 명세 구간이다.
/// - 퀘스트 목록 `518:88395`·`518:88405` · 상세 `518:88429`~`518:88441`
/// - 광고 보상 `523:18244`·`518:88685` · 보상 완료 `518:88630`
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
library;
import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart';
import 'place_picker_screen.dart' show PlacePickerScreen;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
///
/// 명세 주석은 *「자세히」 · 「시작하기」 · 「진행중」 · 「보상받기」 · 「보상완료」* 이지만,
/// 버튼 문구는 시각 기준(원장 C-44)인 Figma 화면을 따른다 — 「퀘스트 시작하기」 ·
/// 「보상받기」 · 「보상 완료」(`264:46867` · `373:433420`). 상세 `272:245274` 는 「보상 받기」로
/// 띄어 썼지만 홈 · 목록 사본 표기로 통일한다.
///
/// 홈 화면의 [QuestState] 와 다른 열거형인 이유는 다루는 것이 다르기 때문이다.
/// 홈은 「오늘 할 것」 요약이고 여기는 퀘스트 하나의 생애다.
enum QuestStage {
/// 내가 만든 퀘스트. 참여 대상이 아니라 관리 대상이라 버튼이 「자세히」다.
mine,
notStarted,
inProgress,
completed,
rewarded;
/// Figma 화면 문구. 임의로 바꾸지 마라 — 화면 대조 대상이다.
String get buttonLabel => switch (this) {
mine => appL10n.questDetails,
notStarted => appL10n.questStart,
inProgress => appL10n.questInProgress,
completed => appL10n.questClaim,
rewarded => appL10n.questRewardDone,
};
/// 누를 수 있는가. 「보상완료」는 명세가 *클릭 불가* 로 정했다.
bool get tappable => this != QuestStage.rewarded;
}
/// 퀘스트 유형 — `quests/{questId}.type`(Firestore 설계서 §2.8).
///
/// 서버의 검증 함수가 유형마다 다르므로(`fn-quest-attend` · `fn-quest-verify-steps` ·
/// `fn-quest-verify-place` · `fn-quest-verify-music-shake`) 앱은 이 값으로 어느 함수를
/// 부를지 고른다.
///
/// **표기 문자열은 확정값이 아니다.** 상세 정의서 8장의 Quest Type 10종과 앱 Figma
/// 카테고리 4종의 관계가 Q-QC-01 회신 대기라, 들어오는 문자열을 별칭까지 받아 옮긴다
/// ([parse]). 모르는 값은 [unknown] 으로 두고 참여를 잠근다 — 임의로 어느 검증 함수에
/// 붙이면 검증 없는 지급 경로가 생긴다.
enum QuestType {
attend,
steps,
place,
musicShake,
/// 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06. 운영이 발동하고 창(`activeFrom`~`activeTo`)
/// 안에서만 참여를 받는다. 판정 시각은 서버 시각이다(`fn-quest-timeattack`).
timeAttack,
/// 스밍·YouTube. 별첨 B 「1차 별도 완청 보상형 Quest 없음」 + Q-QC-04 회신 대기.
streaming,
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(원장 C-08 · C-10).
vote,
sns,
/// 사용자 생성 Community — 응원글 + 다른 회원 반응을 서버가 확인하면 즉시 지급(C-11).
community,
/// 앱이 판정 경로를 아는 유형이 아니다.
unknown;
/// 목록·상세의 분류 표기. 서버가 분류명을 따로 주지 않아 유형에서 낸다.
String get label => switch (this) {
attend => appL10n.questTypeAttend,
steps => appL10n.questTypeSteps,
place => appL10n.questTypePlace,
musicShake => 'Music Shake',
timeAttack => appL10n.questTypeTimeAttack,
streaming => appL10n.questTypeStreaming,
vote => appL10n.questTypeVote,
sns => appL10n.questTypeSns,
community => appL10n.questCatCommunity,
unknown => appL10n.commonEtc,
};
static QuestType parse(String raw) => switch (raw.toLowerCase()) {
'attend' || 'checkin' => QuestType.attend,
'steps' || 'walk' || 'health' => QuestType.steps,
'place' || 'location' => QuestType.place,
'musicshake' || 'shake' => QuestType.musicShake,
'timeattack' => QuestType.timeAttack,
'streaming' || 'stream' || 'youtube' => QuestType.streaming,
'vote' => QuestType.vote,
'sns' => QuestType.sns,
'community' => QuestType.community,
_ => QuestType.unknown,
};
/// 사람 검토형(C-08) — 참여가 곧 지급이 아니라 제출이다.
bool get reviewed => this == QuestType.vote || this == QuestType.sns;
/// 화면에 적는 보상 POP — **서버가 실제로 지급하는 값과 같은 원천**.
///
/// 출석은 `fn-quest-attend` 가 `config/policy.attendPop` 을 지급하고
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
final raw = this == QuestType.attend
? policy['attendPop']
: quest['rewardPop'];
return raw is num ? raw.round() : null;
}
/// 앱이 서버 검증까지 태울 수 있는 유형인가.
///
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
bool get verifiable => switch (this) {
QuestType.attend ||
QuestType.steps ||
QuestType.place ||
QuestType.timeAttack ||
QuestType.vote ||
QuestType.sns ||
QuestType.community => true,
QuestType.musicShake || QuestType.streaming || QuestType.unknown => false,
};
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason => switch (this) {
QuestType.musicShake || QuestType.streaming =>
'발주사 확인 중입니다 — 스밍·Music Shake 유형은 회신 대기 항목입니다(Q-QC-04).',
QuestType.unknown => appL10n.questTypeUnverifiable,
_ => null,
};
}
/// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답.
///
/// **앱이 계산한 값이 아니다.** 지급액과 잔액은 원장을 지난 서버 값이며
/// (별첨 1 ② 「클라이언트 단독 지급 0건」) 화면은 이 값을 그대로 띄운다.
class QuestReward {
const QuestReward({
required this.rewardedPop,
required this.balanceAfter,
this.txId,
this.duplicate = false,
});
final int rewardedPop;
final int balanceAfter;
final String? txId;
/// 이미 받은 보상의 재응답(`duplicate: true`). 새 지급이 아니므로 보상 화면 · 광고
/// 추가 보상을 다시 열지 않는다.
final bool duplicate;
}
/// 퀘스트를 누가 만들었는가. 명세가 관리자 리스트와 사용자 카드를 나눠 두었다.
enum QuestAuthor {
/// 어드민에 등록된 퀘스트. 광고 추가 보상은 기본 보상 × (배수 − 1) 기여 POP(B-03).
admin,
/// 사용자가 만든 퀘스트. 광고 추가 보상은 고정 기여 POP(B-28 · C-02).
user,
}
/// 퀘스트 분류 — `config/questCatalog.categories`(원장 C-01). 순서 · 노출은 Admin 운영값이다.
class QuestCategory {
const QuestCategory({
required this.id,
required this.name,
this.activities = const [],
});
final String id;
final String name;
/// 이 분류에서 **사용자가 만들 수 있는** 활동(노출 · `ugcAllowed`), 순서대로.
final List<QuestActivity> activities;
}
/// 생성 화면의 활동 — `config/questCatalog.activities`. 입력 필드는 [fields] 만 그린다(C-01).
class QuestActivity {
const QuestActivity({
required this.key,
required this.name,
this.fields = const {},
});
final String key;
final String name;
/// `inputSchema` 의 필드 이름 — `link` · `place` 등.
final Set<String> fields;
/// 무보상 기본 참여 Quest(C-04) — 보상 · 인원 입력이 없다.
bool get noReward => key == 'youtube' || key == 'streaming';
}
/// 생성 화면이 쓰는 분류와 운영값(B-30 수수료 · C-06 최대 기간).
class QuestCatalog {
const QuestCatalog({
this.categories = const [],
this.feeRate = 0.1,
this.feeMin = 10,
this.feeOnNoReward = false,
this.maxDays = 30,
});
final List<QuestCategory> categories;
final double feeRate;
final int feeMin;
/// 보상 없는 Quest(Pool 0)에도 최소 수수료를 받는가 — 해석 확인 요청 Q-UQ-02. 권고 A안은 false(0 POP).
final bool feeOnNoReward;
final int maxDays;
/// 수수료 안내값 — Pool 의 10%(올림) · 최소 10, Pool 0 은 [feeOnNoReward] 가 꺼져 있으면 0.
/// 실제 차감은 서버가 다시 계산한다.
int feeOf(int pool) => pool == 0 && !feeOnNoReward
? 0
: (pool * feeRate).ceil() < feeMin
? feeMin
: (pool * feeRate).ceil();
}
/// 위치 Quest 의 등록 장소 — `places/{placeId}`.
class QuestPlace {
const QuestPlace({required this.id, required this.name});
final String id;
final String name;
}
/// 링크 미리보기 — 서버 `fn-link-preview`(C-05). [status] 는 `ok` · `preview_failed` · `invalid`.
class QuestLink {
const QuestLink({
required this.url,
required this.status,
this.title = '',
this.thumbnail,
this.provider,
});
final String url;
final String status;
/// 원본 제목(서버 조회값) 또는 조회 실패 시 생성자가 적은 최소 제목.
final String title;
final String? thumbnail;
final String? provider;
bool get ok => status == 'ok';
bool get failed => status == 'preview_failed';
}
/// 제출 건 — `questSubmissions/{id}`(C-08 · G-01 · G-03). [status] 는
/// `submitted` · `approved` · `paid` · `rejected` · `rejected_final`.
class QuestSubmission {
const QuestSubmission({
required this.id,
required this.status,
this.submittedAt,
this.rejectReason,
this.resubmitAllowed = false,
this.summary = '',
});
final String id;
final String status;
final DateTime? submittedAt;
final String? rejectReason;
final bool resubmitAllowed;
/// 인증 자료 요약(마지막 제출의 링크 · 내용). 생성자 검토 목록에 쓴다.
final String summary;
bool get pending => status == 'submitted' || status == 'approved';
/// 최종 반려 — 검토자가 재제출 불가로 반려했거나, 마감 뒤 보완 대기가 닫힌 건(`rejected_final`).
bool get finalRejected =>
status == 'rejected_final' || (status == 'rejected' && !resubmitAllowed);
bool get rejected => status == 'rejected' || status == 'rejected_final';
/// 주말 포함 24시간 검토 목표를 넘겼는가(C-08). 자동 승인은 없고 안내만 한다.
bool delayed(DateTime now, {int hours = 24}) =>
status == 'submitted' &&
submittedAt != null &&
now.difference(submittedAt!) > Duration(hours: hours);
}
/// 목록 카드 하나.
class QuestCard {
const QuestCard({
required this.id,
required this.title,
required this.categoryName,
required this.author,
required this.stage,
required this.goal,
required this.progress,
required this.rewardPop,
required this.participants,
this.type = QuestType.unknown,
this.capacity,
this.adMultiplier = 2,
this.ugcAdPop = 10,
this.endsAt,
});
final String id;
final String title;
final String categoryName;
/// 검증 경로를 고르는 유형. 표본 데이터는 지정하지 않아 [QuestType.unknown] 이다.
final QuestType type;
final QuestAuthor author;
final QuestStage stage;
/// 설정 목표 수치. 0 이면 수치 목표가 없는 퀘스트다.
final int goal;
/// 실행한 목표 수치.
final int progress;
/// 보상 POP. 운영값을 읽지 못했으면 null 이고 화면은 숫자 대신 중립 문구를 쓴다.
final int? rewardPop;
final int participants;
/// 모집 인원 — 카드의 *참여 인원 : 0 / 5* 분모. 설계서 §2.8 `quests` 에 필드가 없어
/// 서버 경로는 null 이고, 화면은 분모 없이 참여 수만 적는다.
final int? capacity;
/// 운영 Quest 의 광고 배수 — 원장 **B-03** 기본 x2, 지정 Quest 는 `quests/{id}.adMultiplier`.
final int adMultiplier;
/// 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정 10 기여 POP(운영값).
final int ugcAdPop;
/// 참여 마감 시각(`quests.activeTo`). 타임어택만 채운다 — 화면이 남은 시간을 센다.
final DateTime? endsAt;
/// 광고를 끝까지 보면 더 받는 기여 POP 의 **안내값**. 실제 지급액은 서버가 기본 보상
/// 원장 행으로 다시 계산해 돌려준다(`fnAdReward.rewarded`). 보상 POP 을 모르면 null.
int? get adBonusPop => author == QuestAuthor.user
? ugcAdPop
: rewardPop == null
? null
: rewardPop! * (adMultiplier - 1);
/// 0~1. 목표가 없으면 0 이다.
double get ratio => goal == 0 ? 0 : (progress / goal).clamp(0.0, 1.0);
/// 상태·진행도만 바꾼 사본. [QuestCard] 는 불변이라 갈아 끼운다.
QuestCard copyWith({QuestStage? stage, int? progress}) => QuestCard(
id: id,
title: title,
categoryName: categoryName,
author: author,
stage: stage ?? this.stage,
goal: goal,
progress: progress ?? this.progress,
rewardPop: rewardPop,
participants: participants,
type: type,
capacity: capacity,
adMultiplier: adMultiplier,
ugcAdPop: ugcAdPop,
endsAt: endsAt,
);
}
/// 상세 화면이 필요로 하는 것.
class QuestDetail {
const QuestDetail({
required this.card,
required this.imageLabel,
required this.description,
this.link,
this.endsAt,
this.repeatHours,
this.repeatMax = 1,
this.submission,
this.reviews = const [],
});
final QuestCard card;
/// 대표 이미지. 자산이 아직 없어 라벨로 둔다.
final String imageLabel;
/// 사용자 생성 Quest 는 작성자 설명(`userDescription`) — 링크 원본 제목과 다른 필드다(C-05).
final String description;
final QuestLink? link;
/// 서버가 정한 마감 순간(UTC instant). 화면은 기기 시간대로 표시만 바꾼다(C-06 · G-04).
final DateTime? endsAt;
/// 위치 반복 규칙(C-12). 반복 허용이면 간격, 아니면 null(1인 1회).
final int? repeatHours;
final int repeatMax;
/// 내 제출 건(사람 검토형).
final QuestSubmission? submission;
/// 생성자에게만 — 검토 대기 제출 건.
final List<QuestSubmission> reviews;
}
/// 목록 탭. 명세 — *전체 / 진행중 / 완료 / 내 퀘스트*.
enum QuestTab {
all,
inProgress,
done,
mine;
String get label => switch (this) {
all => appL10n.commonAll,
inProgress => appL10n.questInProgress,
done => appL10n.commonDone,
mine => appL10n.questTabMine,
};
bool accepts(QuestCard card) => switch (this) {
QuestTab.all => true,
QuestTab.inProgress => card.stage == QuestStage.inProgress,
QuestTab.done =>
card.stage == QuestStage.completed || card.stage == QuestStage.rewarded,
QuestTab.mine => card.stage == QuestStage.mine,
};
}
/// 생성 화면이 만드는 값 — `fn-quest-create` 입력.
class QuestDraft {
const QuestDraft({
required this.clientSeq,
required this.activityKey,
required this.title,
this.description = '',
this.rewardPop = 0,
this.participants = 0,
this.endDate,
this.url,
this.manualTitle,
this.placeId,
});
/// 같은 화면의 재시도를 한 건으로 묶는 멱등 키.
final String clientSeq;
final String activityKey;
final String title;
final String description;
final int rewardPop;
final int participants;
/// 종료일(날짜만). null 이면 공개 후 24시간(C-06).
final DateTime? endDate;
final String? url;
/// 링크 미리보기 조회 실패 시 생성자가 적은 제목.
final String? manualTitle;
final String? placeId;
}
abstract interface class QuestRepository {
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
Future<List<QuestCard>> load({int limit = 10});
Future<List<QuestCard>> more({required int offset, int limit = 10});
Future<QuestDetail> detail(String questId);
/// 생성 화면의 분류 · 활동과 운영값 — `config/questCatalog` · `config/policy` 를 매번 읽는다(C-01).
Future<QuestCatalog> catalog();
/// 위치 Quest 에 고를 수 있는 등록 장소.
Future<List<QuestPlace>> places();
/// 링크 미리보기(C-05). 저장 때 서버가 다시 조회하므로 화면 표시용이다.
Future<QuestLink> previewLink(String url);
/// 사람 검토형 인증 제출 · 반려 후 보완 제출(C-08 · G-01).
Future<void> submit(String questId, {String? url, String? text});
/// 생성자 검토 — 승인 · 반려(사유 · 재제출 가능 여부).
Future<void> review(
String submissionId, {
required bool approve,
String? reason,
bool resubmitAllowed = false,
});
/// 공개 후 수정 — 제목 · 설명만(C-07).
Future<void> edit(
String questId, {
required String title,
required String description,
});
/// 상태 전이. 서버가 확정한 상태를 돌려준다.
Future<QuestStage> advance(String questId, QuestStage next);
Future<String> create(QuestDraft draft);
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
///
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
/// 수집* 이 이 스트림이다. 값은 **시작 시점 대비 증가분**이며, 권한이 없거나
/// 센서가 없으면 오류로 끊는다.
Stream<int> progressStream(QuestCard card);
/// 활동 증거를 서버에 보내 판정·지급을 받는다.
///
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
/// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 ·
/// E-QST-03 반경 밖 · E-POP-03 중복).
Future<QuestReward> verify(QuestCard card);
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
///
/// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force ·
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」).
Future<RewardedAdHandle> loadRewardedAd();
/// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은
/// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02).
/// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다.
Future<int> claimAdReward(String impressionId, QuestReward base);
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleQuestRepository implements QuestRepository {
const SampleQuestRepository();
/// 원장 C-01 매핑 중 사용자 생성 초기값(C-02 A안)으로 열린 활동만.
static QuestCatalog get _catalog => QuestCatalog(
categories: [
QuestCategory(
id: 'planet',
name: appL10n.questCatPlanet,
activities: [
QuestActivity(
key: 'vote',
name: appL10n.questTypeVote,
fields: const {'link'},
),
QuestActivity(
key: 'sns',
name: appL10n.questTypeSns,
fields: const {'link'},
),
],
),
QuestCategory(
id: 'community',
name: appL10n.questCatCommunity,
activities: [
QuestActivity(key: 'community', name: appL10n.questCatCommunity),
],
),
QuestCategory(
id: 'location',
name: appL10n.questTypePlace,
activities: [
QuestActivity(
key: 'location',
name: appL10n.questTypePlace,
fields: const {'place'},
),
],
),
],
);
/// 유형([QuestType])을 함께 담는다. 유형이 없으면 화면이 어느 검증 경로인지 몰라
/// 참여 버튼을 잠근다 — 표본에서도 상태 전이를 보여 주려면 유형이 있어야 한다.
static final _cards = <QuestCard>[
const QuestCard(
id: 'q1',
title: '만보기 챌린지',
categoryName: '걷기',
type: QuestType.steps,
author: QuestAuthor.admin,
stage: QuestStage.inProgress,
goal: 10000,
progress: 6400,
rewardPop: 50,
participants: 1284,
),
const QuestCard(
id: 'q2',
title: '주말 만보기',
categoryName: '걷기',
type: QuestType.steps,
author: QuestAuthor.admin,
stage: QuestStage.completed,
goal: 10000,
progress: 10000,
rewardPop: 10,
participants: 3420,
),
const QuestCard(
id: 'q3',
title: '오늘의 출석',
categoryName: '출석',
type: QuestType.attend,
author: QuestAuthor.admin,
stage: QuestStage.rewarded,
goal: 1,
progress: 1,
rewardPop: 10,
participants: 8210,
),
const QuestCard(
id: 'q4',
title: '홍대 팝업 방문',
categoryName: '특정 위치',
type: QuestType.place,
author: QuestAuthor.user,
stage: QuestStage.notStarted,
goal: 1,
progress: 0,
rewardPop: 100,
participants: 42,
),
const QuestCard(
id: 'q5',
title: '데뷔일 챌린지 함께해요',
categoryName: '걷기',
type: QuestType.steps,
author: QuestAuthor.user,
stage: QuestStage.mine,
goal: 5000,
progress: 0,
rewardPop: 30,
participants: 8,
),
// 스밍 유형은 Q-QC-04 회신 대기다(해석 확인 요청서 8.1). 화면은 두고 참여만
// 잠근 상태를 표본에서도 보여 준다.
const QuestCard(
id: 'q6',
title: '신곡 3분 완청',
categoryName: '스트리밍',
type: QuestType.streaming,
author: QuestAuthor.admin,
stage: QuestStage.notStarted,
goal: 1,
progress: 0,
rewardPop: 10,
participants: 3420,
),
];
@override
Future<List<QuestCard>> load({int limit = 10}) async =>
_cards.take(limit).map(_withClaim).toList();
@override
Future<List<QuestCard>> more({required int offset, int limit = 10}) async =>
_cards.skip(offset).take(limit).map(_withClaim).toList();
QuestCard _withClaim(QuestCard card) => _claimed.contains(card.id)
? card.copyWith(stage: QuestStage.rewarded, progress: card.goal)
: card;
@override
Future<QuestDetail> detail(String questId) async {
final found = _cards.firstWhere(
(c) => c.id == questId,
orElse: () => _cards.first,
);
final card = _claimed.contains(found.id)
? found.copyWith(stage: QuestStage.rewarded, progress: found.goal)
: found;
return QuestDetail(
card: card,
imageLabel: card.categoryName,
description: '${card.title} — 목표를 채우면 ${card.rewardPop ?? 0} POP 을 받습니다.',
);
}
@override
Future<QuestCatalog> catalog() async => _catalog;
@override
Future<List<QuestPlace>> places() async => [
for (final name in PlacePickerScreen.defaultPlaces)
QuestPlace(id: name, name: name),
];
/// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다.
@override
Future<QuestLink> previewLink(String url) async => QuestLink(
url: url,
status: Uri.tryParse(url)?.hasScheme == true ? 'preview_failed' : 'invalid',
);
@override
Future<void> submit(String questId, {String? url, String? text}) async {}
@override
Future<void> review(
String submissionId, {
required bool approve,
String? reason,
bool resubmitAllowed = false,
}) async {}
@override
Future<void> edit(
String questId, {
required String title,
required String description,
}) async {}
/// 목 모드에서 이미 보상을 받은 퀘스트. 서버가 없으니 앱이 기억한다.
///
/// **표본에만 있는 상태다.** 실 서버에서는 `questProgress` 가 같은 일을 한다.
/// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다.
static final _claimed = <String>{};
@override
Future<QuestStage> advance(String questId, QuestStage next) async => next;
@override
Future<String> create(QuestDraft draft) async => 'q-new';
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
@override
Stream<int> progressStream(QuestCard card) => const Stream<int>.empty();
@override
Future<QuestReward> verify(QuestCard card) async {
_claimed.add(card.id);
return QuestReward(
rewardedPop: card.rewardPop ?? 0,
balanceAfter: 12540 + (card.rewardPop ?? 0),
);
}
/// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다.
@override
Future<RewardedAdHandle> loadRewardedAd() async {
await Future<void>.delayed(const Duration(seconds: 2));
return const _SampleAd();
}
/// 운영 Quest 기본 x2 의 증분(= 기본 보상). 표본이라 한도를 세지 않는다.
@override
Future<int> claimAdReward(String impressionId, QuestReward base) async =>
base.rewardedPop;
/// 표본 상태를 되돌린다. 테스트가 회차마다 같은 자리에서 시작하도록 둔다.
static void resetClaims() => _claimed.clear();
}
class _SampleAd implements RewardedAdHandle {
const _SampleAd();
@override
Future<String?> show() async => 'sample-impression';
@override
void dispose() {}
}
@@ -0,0 +1,868 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
import '../../core/monetization/rewarded_ad.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../home/home_screen.dart' show formatPop;
import '../planet/fpi_ranking_screen.dart' show MockBadge;
import '../planet/moderation.dart';
import 'quest_ad_screens.dart';
import 'quest_create_screen.dart' show QuestLinkCard;
import 'quest_data.dart';
import 'quest_evidence.dart';
import 'quest_reward_screen.dart';
import 'widgets/quest_card_tile.dart';
import 'widgets/time_attack_countdown.dart';
import '../../core/i18n/app_locale.dart';
/// 퀘스트 상세 — 화면 대조 원장 F4.
///
/// Figma 노드
/// - `264:48835` 홈/퀘스트/퀘스트 상세 · `272:245274` 홈_퀘스트/퀘스트 상세
/// - `494:14332` 광고 시청 전 · `482:15084` 광고 시청 후
/// - `548:21261` 신고하기
///
/// 명세 `518:88441` — 상태별 버튼과 동작이 전부 정해져 있다.
/// *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 수집 ·*
/// *「진행중」 퀘스트 포기 알림 노출 · 「보상받기」 관리자 퀘스트 경우 추가 보상 모달*
class QuestDetailScreen extends StatefulWidget {
const QuestDetailScreen({
super.key,
required this.questId,
required this.repository,
});
final String questId;
/// 저장소는 호출부가 준다 — 화면이 표본을 기본값으로 만들지 않는다.
final QuestRepository repository;
@override
State<QuestDetailScreen> createState() => _QuestDetailScreenState();
}
class _QuestDetailScreenState extends State<QuestDetailScreen> {
QuestDetail? _detail;
QuestStage? _stage;
/// 실측 진행도. 명세 `518:88441` 의 *실시간 목표 실행률 수집* 이다.
/// null 이면 아직 수집을 시작하지 않아 서버가 준 값을 그린다.
int? _live;
StreamSubscription<int>? _walk;
/// 서버 왕복 중. 두 번 눌러 두 번 지급되는 경로를 막는다.
bool _busy = false;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_walk?.cancel();
super.dispose();
}
/// 실측 수집을 끈다. 해지 완료를 기다리지 않는다 — 기다릴 이유가 없고,
/// 끝난 스트림에서는 그 Future 가 늦게 완료된다.
void _stopWatching() {
unawaited(_walk?.cancel());
_walk = null;
}
Future<void> _load() async {
final detail = await widget.repository.detail(widget.questId);
if (!mounted) return;
setState(() {
_detail = detail;
_stage = detail.card.stage;
});
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
if (_stage == QuestStage.inProgress && detail.card.type == QuestType.steps) {
_watch(detail.card);
}
}
@override
Widget build(BuildContext context) {
final detail = _detail;
final stage = _stage;
if (detail == null || stage == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final card = detail.card;
final image = questImage(card);
final blocked = card.type.blockedReason;
final submission = detail.submission;
final ugc = card.author == QuestAuthor.user;
final endsAt = card.endsAt;
return Scaffold(
appBar: AppBar(
actions: [
PopupMenuButton<String>(
tooltip: context.l10n.commonMore,
icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24, color: AppColors.onCosmic),
onSelected: (value) =>
value == 'edit' ? _edit(detail) : _report(card.title),
itemBuilder: (_) => [
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
if (stage == QuestStage.mine && ugc)
PopupMenuItem(value: 'edit', child: Text(context.l10n.questEdit)),
PopupMenuItem(value: 'report', child: Text(context.l10n.reportTitle)),
],
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
children: [
// 명세 `518:88429` — 대표 이미지 · 카테고리 · 퀘스트 명 · 중앙 정렬.
// 시안 `264:48835` — #2e2e2e 240 원 안에 200 이미지.
Center(
child: Container(
width: 240,
height: 240,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Color(0xFF2E2E2E),
shape: BoxShape.circle,
),
child: image == null
? null
: Image.asset(image, width: 200, height: 200),
),
),
const SizedBox(height: 24),
Text(
card.categoryName,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
color: Color(0xFF777777),
),
),
const SizedBox(height: 8),
Text(
card.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 24),
// 링크 원본(서버 조회값)과 작성자 설명을 나눠 그린다(C-05 · R10).
if (detail.link != null) ...[
QuestLinkCard(link: detail.link!),
const SizedBox(height: 12),
],
if (ugc && detail.description.isNotEmpty) ...[
Text(context.l10n.questAuthorNote, style: _caption),
const SizedBox(height: 4),
Text(detail.description, style: _body),
const SizedBox(height: 24),
],
// 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP.
_Row(
label: context.l10n.questGoal,
value: '${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}',
),
const SizedBox(height: 8),
_Row(
label: context.l10n.questReward,
bolt: card.rewardPop != null,
value: card.rewardPop == null ? context.l10n.questPopGrant : '+${formatPop(card.rewardPop!)}',
),
// 타임어택 — 남은 시간. 마감 판정은 서버 시각이 다시 한다(FR-QST-06).
if (endsAt != null) ...[
const SizedBox(height: 8),
TimeAttackCountdown(
endsAt: endsAt,
builder: (context, left) => _Row(
label: context.l10n.questTimeLeft,
value: left == null ? context.l10n.questTimeAttackClosed : formatLeft(left),
),
),
],
const SizedBox(height: 8),
// 마감 순간은 서버값 그대로, 표시만 기기 시간대(C-06 · G-04).
if (detail.endsAt != null) ...[
_Row(label: context.l10n.questDeadline, value: _deadline(detail.endsAt!)),
const SizedBox(height: 8),
],
// 참여 전 간격 · 최대 횟수 안내(C-12).
if (card.type == QuestType.place)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Text(
detail.repeatHours == null
? context.l10n.questRepeatOnce
: context.l10n.questRepeatEvery('${detail.repeatHours}', '${detail.repeatMax}'),
style: _body,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 24),
child: Row(
children: [
Image.asset('assets/figma/quest-check.png', width: 20, height: 20),
const SizedBox(width: 8),
Expanded(child: Text(context.l10n.questParticipants, style: _strong)),
Text(
card.capacity == null
? formatPop(card.participants)
: '${formatPop(card.participants)} / ${formatPop(card.capacity!)}',
style: _strong,
),
],
),
),
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
// 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다.
if (blocked != null) MockBadge(note: blocked),
if (submission != null) ..._submissionStatus(submission),
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
],
),
// 시안 — 내가 만든 퀘스트는 하단 버튼이 없다(`264:48835`). 나머지는 하단 고정 56 버튼.
bottomNavigationBar: stage == QuestStage.mine
? null
: endsAt == null
// 사용자 생성 Quest 는 서버 시각 기준 마감이 지나면 잠근다(C-06 · G-04). 판정은 서버가 다시 한다.
? _button(detail, stage, blocked,
closed: detail.endsAt != null && !ServerClock.now().isBefore(detail.endsAt!))
: TimeAttackCountdown(
endsAt: endsAt,
builder: (_, left) =>
_button(detail, stage, blocked, closed: left == null),
),
);
}
/// 하단 고정 56 버튼. 타임어택은 마감([closed])되면 잠근다.
Widget _button(
QuestDetail detail,
QuestStage stage,
String? blocked, {
required bool closed,
}) {
final colors = questButtonColors(stage);
final open = !closed || stage == QuestStage.rewarded;
final submission = detail.submission;
// 사람 검토형의 제출 상태(C-08 · G-03) — 검토 중 · 최종 반려는 다시 누를 수 없다.
final held = submission != null && (submission.pending || submission.finalRejected);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: stage.tappable && !_busy && blocked == null && open && !held
? () => _act(detail, stage)
: null,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
backgroundColor: colors.bg,
foregroundColor: colors.fg,
disabledBackgroundColor: const Color(0xFF333333),
disabledForegroundColor: const Color(0xFF777777),
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 24 / 16,
),
),
child: Text(
blocked != null
? '발주사 확인 중'
: !open
? context.l10n.questTimeAttackClosed
: _busy
? context.l10n.commonChecking
: submission != null && submission.pending
? context.l10n.questReviewButton
: submission != null && submission.rejected
? (!submission.finalRejected
? context.l10n.questResubmit
: context.l10n.questRejectedButton)
: detail.card.type.reviewed && stage == QuestStage.notStarted
? context.l10n.questSubmit
: stage.buttonLabel,
),
),
),
);
}
static const _caption = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
height: 18 / 13,
color: Color(0xFF777777),
);
static const _body = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.4,
color: AppColors.onCosmic,
);
static String _two(int n) => n.toString().padLeft(2, '0');
/// 기기 시간대의 마감 시각과 남은 시간.
String _deadline(DateTime endsAt) {
final local = endsAt.toLocal();
final at = '${local.year}.${_two(local.month)}.${_two(local.day)} '
'${_two(local.hour)}:${_two(local.minute)}';
final left = endsAt.difference(ServerClock.now());
return left.isNegative
? '$at · ${context.l10n.questEnded}'
: '$at · ${context.l10n.questRemainingHours('${left.inHours}')}';
}
/// 내 제출 건 안내(C-08) — 검토 중 · 지연 · 승인 후 지급 · 반려 사유와 재제출 가능 여부.
List<Widget> _submissionStatus(QuestSubmission s) {
final lines = <String>[
if (s.status == 'submitted') context.l10n.questReviewPending,
if (s.delayed(ServerClock.now())) context.l10n.questReviewDelayed,
if (s.status == 'approved') context.l10n.questApprovedPaying,
if (s.rejected) ...[
context.l10n.questRejected(s.rejectReason ?? ''),
!s.finalRejected
? context.l10n.questResubmitAllowed
: context.l10n.questResubmitNotAllowed,
],
];
return [for (final line in lines) Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Text(line, style: _body),
)];
}
/// 생성자 검토 대기열 — 승인 · 반려. 시간이 지나도 자동 승인하지 않는다(C-08).
List<Widget> _reviewQueue(QuestDetail detail) => [
const SizedBox(height: 16),
Text(context.l10n.questReviewQueue('${detail.reviews.length}'), style: _strong),
for (final s in detail.reviews)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(s.summary, style: _body),
subtitle: s.delayed(ServerClock.now())
? Text(context.l10n.questReviewDelayed, style: _caption)
: null,
trailing: Wrap(
spacing: 4,
children: [
TextButton(
onPressed: _busy ? null : () => _review(s, approve: true),
child: Text(context.l10n.questApprove),
),
TextButton(
onPressed: _busy ? null : () => _review(s, approve: false),
child: Text(context.l10n.questReject),
),
],
),
),
];
Future<void> _review(QuestSubmission s, {required bool approve}) async {
String? reason;
var resubmit = false;
if (!approve) {
final input = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialog) => AlertDialog(
title: Text(context.l10n.questReject),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: input,
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: resubmit,
onChanged: (v) => setDialog(() => resubmit = v ?? false),
title: Text(context.l10n.questAllowResubmit),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questReject),
),
],
),
),
);
reason = input.text.trim();
input.dispose();
if (ok != true || reason.isEmpty) return;
}
await _run(() => widget.repository.review(
s.id,
approve: approve,
reason: reason,
resubmitAllowed: resubmit,
));
}
/// 인증 제출 · 보완 제출(C-08 · G-01) — 링크 또는 내용 중 하나 이상.
Future<void> _submitProof(QuestDetail detail) async {
final url = TextEditingController();
final text = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questSubmit),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: url,
keyboardType: TextInputType.url,
decoration: InputDecoration(labelText: context.l10n.questEvidenceUrl),
),
TextField(
controller: text,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(labelText: context.l10n.questEvidenceText),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonSubmit),
),
],
),
);
final proofUrl = url.text.trim();
final proofText = text.text.trim();
url.dispose();
text.dispose();
if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return;
final done = await _run(
() => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText),
);
if (done && mounted) _toast(context.l10n.questSubmitted);
}
/// 제목 · 설명만 고친다(C-07). 잠금 필드는 입력 자체가 없다.
Future<void> _edit(QuestDetail detail) async {
final title = TextEditingController(text: detail.card.title);
final description = TextEditingController(text: detail.description);
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questEdit),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.questEditNotice, style: _caption),
TextField(
controller: title,
decoration: InputDecoration(labelText: context.l10n.questName),
),
TextField(
controller: description,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(labelText: context.l10n.questDescriptionOptional),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonEdit),
),
],
),
);
final newTitle = title.text.trim();
final newDescription = description.text.trim();
title.dispose();
description.dispose();
if (ok != true || newTitle.isEmpty) return;
await _run(() => widget.repository.edit(
detail.card.id,
title: newTitle,
description: newDescription,
));
}
/// 서버 호출 한 번 — 실패는 알리고, 성공하면 상세를 다시 읽는다.
Future<bool> _run(Future<void> Function() call) async {
setState(() => _busy = true);
try {
await call();
} catch (error) {
if (!mounted) return false;
setState(() => _busy = false);
_fail(error);
return false;
}
if (!mounted) return false;
setState(() => _busy = false);
await _load();
return true;
}
static const _strong = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
height: 20 / 15,
color: AppColors.onCosmic,
);
Future<void> _act(QuestDetail detail, QuestStage stage) async {
switch (stage) {
case QuestStage.notStarted when detail.card.type.reviewed:
await _submitProof(detail);
case QuestStage.notStarted:
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
if (detail.card.goal > 1 && detail.card.type == QuestType.steps) {
await _start(detail);
} else {
await _claim(detail);
}
case QuestStage.inProgress:
await _confirmStop(detail);
case QuestStage.completed:
await _claim(detail);
case QuestStage.mine:
_toast(context.l10n.questMine('${detail.card.participants}'));
case QuestStage.rewarded:
break;
}
}
/// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다.
Future<void> _start(QuestDetail detail) async {
final next =
await widget.repository.advance(detail.card.id, QuestStage.inProgress);
if (!mounted) return;
setState(() {
_stage = next;
_live = 0;
});
_watch(detail.card);
}
/// 실시간 목표 실행률. 목표를 채우면 버튼이 「보상받기」로 바뀐다.
///
/// **여기서 지급하지 않는다.** 목표 도달 판정도 서버가 다시 하며(E-QST-01),
/// 이 값은 화면 진행률과 서버에 보낼 증거로만 쓴다.
void _watch(QuestCard card) {
_walk?.cancel();
_walk = widget.repository.progressStream(card).listen(
(value) {
if (!mounted) return;
setState(() {
_live = value;
if (card.goal > 0 && value >= card.goal) {
_stage = QuestStage.completed;
}
});
},
onError: (Object error) {
// 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시
// 시도할 수 있고, 미동의 상태에서도 화면은 그대로 쓸 수 있다(B-27).
if (!mounted) return;
setState(() {
_stage = QuestStage.notStarted;
_live = null;
});
_fail(error);
},
);
}
/// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·*
/// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트*
Future<void> _confirmStop(QuestDetail detail) async {
final stop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questStopConfirm),
content: Text(context.l10n.questStopBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questStop),
),
],
),
);
if (stop != true) return;
_stopWatching();
final next =
await widget.repository.advance(detail.card.id, QuestStage.notStarted);
if (mounted) {
setState(() {
_stage = next;
_live = null;
});
}
}
/// 보상받기.
///
/// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만
/// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
Future<void> _claim(QuestDetail detail) async {
if (_busy) return;
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
if (!mounted) return;
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
String? impression;
if (bonus) {
impression = await _watchAd();
if (!mounted) return;
}
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
// 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) —
// 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다.
setState(() => _busy = true);
final QuestReward reward;
int? adPop;
BackendException? adError;
try {
final pending = () async {
final base = await widget.repository.verify(detail.card);
if (impression != null && !base.duplicate) {
try {
adPop = await widget.repository.claimAdReward(impression, base);
} on BackendException catch (error) {
adError = error;
}
}
return base;
}();
// 광고 시청 후 대기(`482:15084`) — 서버가 판정·지급하는 동안 띄운다.
if (impression != null) {
unawaited(_warp(QuestAdWarpScreen.after(until: pending)));
}
reward = await pending;
} catch (error) {
if (!mounted) return;
setState(() => _busy = false);
_fail(error);
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
if (error is BackendException &&
error.code == 'E-QST-01' &&
detail.card.type == QuestType.timeAttack) {
await _load();
}
return;
}
_stopWatching();
if (!mounted) return;
setState(() {
_busy = false;
_stage = QuestStage.rewarded;
});
// 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다.
if (reward.duplicate) {
_toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop)));
await _load();
return;
}
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
final failed = adError;
if (failed != null) _toast(failed.message);
await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
builder: (_) =>
QuestRewardScreen(detail: detail, reward: reward, adBonusPop: adPop),
),
);
if (!mounted) return;
// 서버 상태를 다시 읽는다. 걸음 퀘스트는 구간을 하나 받아도 위 구간이 남아 있어
// (B-26 은 하루에 네 구간이다) 「보상완료」로 굳히면 남은 구간을 받을 수 없다.
await _load();
}
/// 광고 시청 전 대기(`494:14332`)를 띄운 채 광고를 불러와 보여 준다.
///
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
Future<String?> _watchAd() async {
RewardedAdHandle? ad;
BackendException? failure;
final loading = widget.repository.loadRewardedAd().then<void>(
(handle) => ad = handle,
onError: (Object error) => failure = error is BackendException
? error
: BackendException('E-AD-01', context.l10n.adLoadFailed),
);
await _warp(QuestAdWarpScreen.before(until: loading));
final loaded = ad;
if (!mounted) {
loaded?.dispose();
return null;
}
if (loaded == null) {
final error = failure;
if (error != null) {
_toast(error.message);
} else {
// 건너뛰기 — 아직 로딩 중인 광고는 도착하는 대로 버린다.
unawaited(loading.then((_) => ad?.dispose()));
}
return null;
}
final impression = await loaded.show();
if (impression == null && mounted) {
_toast(context.l10n.adNotFinished);
}
return impression;
}
/// 실패를 사용자 말로 옮긴다. 분기는 에러 카탈로그 코드로 한다 —
/// gRPC 상태는 여러 사유가 겹쳐 들어온다(`Backend.call`).
void _fail(Object error) {
final message = error is BackendException
? error.message
: context.l10n.commonRequestFailed;
final permission = error is BackendException && error.code == 'E-PERM-01';
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
action: permission
? SnackBarAction(
label: context.l10n.openSettings,
onPressed: openQuestPermissionSettings,
)
: null,
));
}
Future<void> _warp(QuestAdWarpScreen screen) =>
Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(builder: (_) => screen),
);
Future<void> _report(String title) async {
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(authorName: title, title: context.l10n.questNamed(title)),
),
),
);
if (reason != null && mounted) _toast(context.l10n.reportReceived);
}
void _toast(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
}
/// 목표·보상 줄 — #1a1a1c r24 · 높이 62 · 안쪽 20.
class _Row extends StatelessWidget {
const _Row({required this.label, required this.value, this.bolt = false});
final String label;
final String value;
/// 보상 POP 앞 번개(민트).
final bool bolt;
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minHeight: 62),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
),
if (bolt) ...[
Image.asset('assets/figma/icon-bolt.png', width: 16, height: 16.7),
const SizedBox(width: 4),
],
// 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게.
Flexible(
child: Text(
value,
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: -0.4,
color: AppColors.onCosmic,
),
),
),
],
),
);
}
+148
View File
@@ -0,0 +1,148 @@
/// 퀘스트 증거 수집 — 단말에서 얻는 값만 다룬다.
///
/// **여기서 판정하지 않는다.** 별첨 1 ③ 이 「보상 전량 서버검증 경유」를 요구하므로
/// 이 파일은 걸음 수와 좌표를 모아 넘기는 데까지만 관여하고, 완주·반경·조작 판정은
/// 서버(`fn-quest-verify-steps` · `fn-quest-verify-place`)가 다시 한다.
///
/// 저장소가 이 함수들을 부르고, 화면은 부르지 않는다. 테스트는 [FirestoreQuestRepository]
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
library;
import 'package:geolocator/geolocator.dart';
import 'package:pedometer/pedometer.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
/// 좌표 한 점. `fn-quest-verify-place` 가 받는 증거다.
class PlaceFix {
const PlaceFix({
required this.lat,
required this.lng,
required this.accuracyM,
required this.mocked,
});
final double lat;
final double lng;
final double accuracyM;
/// 모의 위치 앱으로 만든 좌표인가. 설계서 §8 이 「mock location 차단」을
/// 판정 항목으로 두었으므로 **판정하지 않고 서버에 그대로 실어 보낸다.**
final bool mocked;
}
/// 부팅 이후 누적 걸음 수 스트림을 내는 함수.
typedef StepCountProbe = Stream<int> Function();
/// 현재 좌표 한 점을 내는 함수.
typedef PlaceProbe = Future<PlaceFix> Function();
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
///
/// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로
/// 증가분을 낸다([FirestoreQuestRepository.progressStream]).
///
/// Android 는 API 29 부터 `ACTIVITY_RECOGNITION` 이 런타임 권한이다. 권한이 없으면
/// 센서 채널이 조용히 빈 스트림으로 남아 「진행률이 0 에서 멈춘다」로만 보이므로,
/// 구독 전에 권한을 먼저 받고 거부 사유를 카탈로그 코드로 올린다.
Stream<int> deviceStepCount() async* {
await _requireActivityPermission();
yield* Pedometer.stepCountStream.map((event) => event.steps);
}
Future<void> _requireActivityPermission() async {
final status = await Permission.activityRecognition.request();
if (status.isGranted || status.isLimited) return;
throw BackendException(
'E-PERM-01',
status.isPermanentlyDenied
? appL10n.permStepsDenied
: appL10n.permStepsNeeded,
);
}
/// 현재 좌표 — `geolocator`.
///
/// 별첨 B **B-27** 이 「탐색은 가능하나 보상 인증은 GPS/Geofence 필수」로 확정했으므로
/// 권한이 없을 때 막는 것은 **인증뿐**이다. 목록·상세는 권한 없이 열린다.
Future<PlaceFix> devicePlaceFix() async {
if (!await Geolocator.isLocationServiceEnabled()) {
throw BackendException('E-PERM-01', appL10n.permLocationOff);
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied) {
throw BackendException('E-PERM-01', appL10n.permLocationNeeded);
}
if (permission == LocationPermission.deniedForever) {
throw BackendException('E-PERM-01', appL10n.permLocationDenied);
}
// 반경 판정을 서버가 하므로 정확도를 높게 요청한다. 낮은 정확도로 받은 좌표는
// 반경 100m(B-27) 판정에서 오차가 그대로 판정 결과가 된다.
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high),
);
return PlaceFix(
lat: position.latitude,
lng: position.longitude,
accuracyM: position.accuracy,
mocked: position.isMocked,
);
}
/// 권한을 영구 거부한 사용자를 설정 화면으로 보낸다. 화면의 「설정 열기」가 부른다.
Future<void> openQuestPermissionSettings() => openAppSettings();
/// 오늘 걸은 수 — **하루 단위 기준선**.
///
/// 걸음 센서가 주는 값은 [deviceStepCount] 설명대로 **부팅 이후 누적**이다. 정책 원장
/// **B-26** 의 구간(1 천·5 천·1 만·1.5 만보)은 **그 날 걸은 수** 기준이므로, 하루가
/// 시작될 때의 누적값을 기준선으로 남겨 두고 그 차이를 오늘치로 본다.
///
/// 기준선을 화면이나 세션이 아니라 **단말에 남기는 이유**는 앱을 껐다 켜는 일이 흔하기
/// 때문이다. 세션에 두면 앱을 다시 열 때마다 오늘치가 0 으로 돌아가 구간을 다시 받게
/// 되는 것처럼 보이고(실제 지급은 서버가 막는다) 진행률도 사실과 달라진다.
///
/// 날짜 경계는 **KST** 다. 서버의 `dayKey()` 와 같은 기준이어야 화면의 「오늘」과 서버가
/// 세는 「오늘」이 어긋나지 않는다.
class WalkBaseline {
WalkBaseline({SharedPreferences? preferences}) : _preferences = preferences;
static const _dayKeyName = 'fanuzu.walk.baseline.day';
static const _stepsKeyName = 'fanuzu.walk.baseline.steps';
SharedPreferences? _preferences;
/// 누적 걸음 [cumulative] 에서 오늘치를 낸다.
///
/// ponytail: 단말을 재부팅하면 센서 누적이 0 으로 돌아가므로 기준선을 다시 잡는다 —
/// 재부팅 전에 걸은 만큼은 오늘치에서 빠진다. 서버가 판정하는 값이 줄어드는 쪽이라
/// 과지급으로 이어지지 않는다. 하루치 합계를 정확히 맞추려면 Health Connect·HealthKit
/// (선검증리포트의 `health` 13.3.1)으로 바꿔야 하고, 그때 최소 지원 OS 를 다시 잰다.
Future<int> todaySteps(int cumulative, {DateTime? now}) async {
final prefs = _preferences ??= await SharedPreferences.getInstance();
final today = kstDayKey(now ?? DateTime.now());
final storedDay = prefs.getString(_dayKeyName);
var baseline = prefs.getInt(_stepsKeyName);
// 날이 바뀌었거나(어제 기준선) 센서가 되감겼다(재부팅). 둘 다 기준선을 다시 잡는다.
if (storedDay != today || baseline == null || baseline > cumulative) {
baseline = cumulative;
await prefs.setString(_dayKeyName, today);
await prefs.setInt(_stepsKeyName, baseline);
}
return cumulative - baseline;
}
}
/// 일일 한도의 리셋 단위 — 서버 `dayKey()` 와 같은 기준(KST)이다.
String kstDayKey(DateTime at) =>
at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
@@ -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),
),
),
),
),
),
],
),
);
}
@@ -0,0 +1,564 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
import '../../core/monetization/rewarded_ad.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
import '../../core/i18n/app_locale.dart';
/// 퀘스트의 실 서버 구현 — 화면 대조 원장 F4.
///
/// **읽기는 Firestore, 재화가 오가는 쓰기는 Cloud Functions.** 별첨 1 ② 가
/// 「클라이언트 단독 지급 0건」을 판정 항목으로 두었으므로 보상은 전부
/// `fn-quest-attend` · `fn-quest-verify-steps` · `fn-quest-verify-place` 를 지난다.
/// 이 파일에 잔액·원장을 쓰는 코드는 없다.
///
/// 읽는 곳
/// - 정의 `quests/{questId}` (설계서 §2.8 — `type` · `title` · `rewardPop` · `dailyLimit`)
/// - 진행 `users/{uid}/questProgress/{questId}` (설계서 §2.1 — `dayKey` · `count`)
/// - 운영값 `config/policy` (걸음 목표. 앱에 하드코딩하지 않는다 — 별첨 B 「운영값 관리 원칙」)
///
/// **설계서에 없어서 채우지 못한 것**은 0 이나 빈 값으로 두고 보고서에 남겼다 —
/// 참여 인원·목록 정렬 키·분류 마스터·위치 퀘스트의 장소 연결 필드가 그렇다.
/// 없는 필드를 여기서 만들면 Admin 이 쓰지 않는 값을 앱만 읽는 상태가 된다.
class FirestoreQuestRepository implements QuestRepository {
FirestoreQuestRepository(
this._backend, {
StepCountProbe stepCount = deviceStepCount,
PlaceProbe placeFix = devicePlaceFix,
WalkBaseline? baseline,
}) : _stepCount = stepCount,
_placeFix = placeFix,
_baseline = baseline ?? WalkBaseline();
final Backend _backend;
final StepCountProbe _stepCount;
final PlaceProbe _placeFix;
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
final WalkBaseline _baseline;
/// 마지막 페이지의 끝 문서. `offset` 대신 커서로 넘긴다 — Firestore 에 offset 이 없다.
DocumentSnapshot<Map<String, dynamic>>? _cursor;
/// 오늘 걸은 수. 마지막으로 센서에서 받은 값이며 서버에 그대로 보낸다.
int _walkSteps = 0;
@override
Future<List<QuestCard>> load({int limit = 10}) async {
_cursor = null;
return _page(limit);
}
@override
Future<List<QuestCard>> more({required int offset, int limit = 10}) =>
_page(limit);
Future<List<QuestCard>> _page(int limit) async {
final uid = _backend.requireUid;
final db = _backend.db;
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다).
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다.
var query = db.collection('quests').limit(limit);
final cursor = _cursor;
if (cursor != null) query = query.startAfterDocument(cursor);
final snap = await query.get();
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
// 타임어택 창은 서버 시각이다. 타임어택이 있을 때만 서버 시계를 잰다(세션당 1회).
if (snap.docs.any((d) => d.data()['type'] == 'timeattack')) {
await ServerClock.ensure(_backend);
}
final now = ServerClock.now();
final policy = await _policy();
// 명세 `518:88395` 의 *퀘스트 생성 최신순* 은 페이지 안에서만 맞춘다. 서버 정렬은
// 걸지 않았다 — `quests` 에 정렬 키가 정해져 있지 않고, `orderBy` 를 걸면 그 필드가
// 없는 문서가 목록에서 통째로 빠진다.
final docs = [...snap.docs]..sort((a, b) {
final left = _writtenAt(a.data());
final right = _writtenAt(b.data());
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
});
final cards = <QuestCard>[];
for (final doc in docs) {
final quest = doc.data();
if (!_open(quest, now, uid)) continue;
final progress = await db
.doc('users/$uid/questProgress/${doc.id}')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
}
return cards;
}
/// 문서가 쓰인 시각. Admin 이 쓴 문서에는 `createdAt` 이 없고 `updatedAt` 만 있다
/// (`fn-admin-write`). 둘 다 없으면 정렬 근거가 없다는 뜻이라 null 이다.
DateTime? _writtenAt(Map<String, dynamic> quest) {
for (final key in ['createdAt', 'updatedAt']) {
final value = quest[key];
if (value is Timestamp) return value.toDate();
}
return null;
}
/// 지금 참여할 수 있는 기간인가 — 설계서 §2.8 `activeFrom`/`activeTo`.
///
/// 두 필드가 없는 문서도 있어(시연 데이터는 `active` 불리언을 쓴다) **있는
/// 조건만 본다.** 없는 필드를 근거로 감추면 목록이 이유 없이 비어 보인다.
bool _open(Map<String, dynamic> quest, DateTime now, String uid) {
// 사용자 생성 Quest — 공개 전(Draft) · 취소는 감추고, 마감분은 생성자의 「내 퀘스트」에만 남긴다.
final status = quest.strOr('status');
if (status == 'draft' || status == 'cancelled') return false;
final mine = quest.strOr('authorUid') == uid;
final endsAt = quest['endsAt'];
if (!mine && (status == 'ended' || (endsAt is Timestamp && !now.isBefore(endsAt.toDate())))) {
return false;
}
if (mine && status.isNotEmpty) return true;
if (quest['active'] == false) return false;
final from = quest['activeFrom'];
if (from is Timestamp && now.isBefore(from.toDate())) return false;
final to = quest['activeTo'];
if (to is Timestamp && now.isAfter(to.toDate())) return false;
return true;
}
QuestCard _card(
String id,
Map<String, dynamic> quest,
Map<String, dynamic> progress,
String uid,
Map<String, dynamic> policy, [
QuestSubmission? submission,
]) {
final type = QuestType.parse(quest.strOr('type'));
final authorUid = quest.strOr('authorUid');
final dailyLimit = quest.intOr('dailyLimit', 1);
// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
final sameDay = type == QuestType.timeAttack ||
progress.strOr('dayKey') == _dayKey(DateTime.now());
final count = sameDay ? progress.intOr('count') : 0;
// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘
// 받은 구간 수가 `count` 이므로 다음 문턱이 지금 목표다. 나머지 유형은 일일 한도가
// 곧 목표 횟수다 — 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
final tiers = _stepTiers(policy);
final steps = type == QuestType.steps;
final done = steps ? count >= tiers.length : count >= dailyLimit;
final goal = steps
? tiers[count.clamp(0, tiers.length - 1)]
: dailyLimit;
return QuestCard(
id: id,
title: _title(quest),
categoryName: type.label,
type: type,
author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user,
stage: authorUid == uid
? QuestStage.mine
: done || submission?.status == 'paid'
? QuestStage.rewarded
// 검토 대기는 「진행중」 탭에 둔다 — 지급 전이라 완료가 아니다(G-03).
: submission?.pending == true
? QuestStage.inProgress
: QuestStage.notStarted,
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
progress: steps ? (done ? goal : 0) : count,
rewardPop: type.rewardFrom(quest, policy),
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
participants: quest.intOr('participantCount'),
// 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다.
capacity: quest['capacity'] is num ? (quest['capacity'] as num).round() : null,
// 광고 안내값 — 서버 `fnAdReward` 와 같은 원천(B-03 · C-02 운영값).
adMultiplier: quest.intOr('adMultiplier', policy.intOr('adMultiplier', 2)),
ugcAdPop: policy.intOr('adRewardPop', 10),
endsAt: type == QuestType.timeAttack && quest['activeTo'] is Timestamp
? (quest['activeTo'] as Timestamp).toDate()
: null,
);
}
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
///
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
List<int> _stepTiers(Map<String, dynamic> policy) {
final raw = policy['stepTiers'];
if (raw is List && raw.isNotEmpty) {
final steps = <int>[];
for (final row in raw) {
if (row is Map && row['steps'] is num) {
steps.add((row['steps'] as num).round());
}
}
if (steps.isNotEmpty) return steps..sort();
}
return [policy.intOr('stepsGoal', 10000)];
}
/// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다.
/// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다.
String _title(Map<String, dynamic> quest) {
final raw = quest['title'];
if (raw is String) return raw;
if (raw is Map) {
final map = Map<String, dynamic>.from(raw);
return map.strOr('ko', map.strOr('en'));
}
return '';
}
Future<Map<String, dynamic>> _policy() async {
final snap = await _backend.db.doc('config/policy').get();
return snap.data() ?? const <String, dynamic>{};
}
@override
Future<QuestDetail> detail(String questId) async {
final uid = _backend.requireUid;
final db = _backend.db;
final questSnap = await db.doc('quests/$questId').get();
final quest = questSnap.data() ?? const <String, dynamic>{};
final progress = await db
.doc('users/$uid/questProgress/$questId')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
final submission = await _submission(questId, quest);
final card = _card(questId, quest, progress, uid, await _policy(), submission);
final link = quest['link'];
final repeat = quest['repeat'] is Map ? Map<String, dynamic>.from(quest['repeat'] as Map) : null;
final endsAt = quest['endsAt'];
return QuestDetail(
card: card,
imageLabel: card.categoryName,
description: quest.strOr('userDescription', quest.strOr('description')),
link: link is Map ? _link(Map<String, dynamic>.from(link)) : null,
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
repeatHours: repeat?['allowed'] == true ? repeat!.intOr('intervalHours', 24) : null,
repeatMax: repeat?['allowed'] == true ? repeat!.intOr('maxCount', 1) : 1,
submission: submission,
reviews: quest.strOr('authorUid') == uid ? await _reviews(questId, uid) : const [],
);
}
QuestLink _link(Map<String, dynamic> m) => QuestLink(
url: m.strOr('url'),
status: m.strOr('status'),
title: m.strOr('title'),
thumbnail: m.strOr('thumbnail').isEmpty ? null : m.strOr('thumbnail'),
provider: m.strOr('provider').isEmpty ? null : m.strOr('provider'),
);
QuestSubmission _submissionOf(String id, Map<String, dynamic> m) {
final evidence = m['evidence'] is List ? m['evidence'] as List : const [];
final last = evidence.isNotEmpty && evidence.last is Map
? Map<String, dynamic>.from(evidence.last as Map)
: const <String, dynamic>{};
final at = m['resubmittedAt'] ?? m['submittedAt'];
return QuestSubmission(
id: id,
status: m.strOr('status'),
submittedAt: at is Timestamp ? at.toDate() : null,
rejectReason: m.strOr('rejectReason').isEmpty ? null : m.strOr('rejectReason'),
resubmitAllowed: m['resubmitAllowed'] == true,
summary: [last.strOr('url'), last.strOr('text')].where((v) => v.isNotEmpty).join('\n'),
);
}
/// 내 제출 건. 사용자 생성 Quest 만 있다(문서 ID `{questId}_{uid}`).
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
if (quest.strOr('authorUid').isEmpty) return null;
final id = '${questId}_${_backend.requireUid}';
final snap = await _backend.db.doc('questSubmissions/$id').get();
final data = snap.data();
return data == null ? null : _submissionOf(id, data);
}
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다.
Future<List<QuestSubmission>> _reviews(String questId, String uid) async {
final snap = await _backend.db
.collection('questSubmissions')
.where('questId', isEqualTo: questId)
.where('questAuthorUid', isEqualTo: uid)
.get();
return [
for (final d in snap.docs)
if (d.data().strOr('status') == 'submitted') _submissionOf(d.id, d.data()),
];
}
/// 분류 마스터(C-01) — 앱 재배포 없이 Admin 이 바꾼 순서 · 노출 · `ugcAllowed` 를 그대로 따른다.
/// 사용자가 만들 수 있는 활동이 하나도 없는 분류는 보이지 않는다.
@override
Future<QuestCatalog> catalog() async {
final raw = await _backend.db
.doc('config/questCatalog')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
final policy = await _policy();
List<Map<String, dynamic>> rows(String key) => [
for (final r in (raw[key] is List ? raw[key] as List : const []))
if (r is Map && r['visible'] != false) Map<String, dynamic>.from(r),
]..sort((a, b) => a.intOr('order').compareTo(b.intOr('order')));
final activities = [for (final a in rows('activities')) if (a['ugcAllowed'] == true) a];
final categories = <QuestCategory>[];
for (final c in rows('categories')) {
final mine = [
for (final a in activities)
if (a.strOr('categoryKey') == c.strOr('key'))
QuestActivity(
key: a.strOr('key'),
name: _activityName(a.strOr('key'), a.strOr('label', a.strOr('key'))),
fields: {
for (final f in (a['inputSchema'] is List ? a['inputSchema'] as List : const []))
if (f is Map) '${f['field']}',
},
),
];
if (mine.isEmpty) continue;
categories.add(QuestCategory(
id: c.strOr('key'),
name: _categoryName(c.strOr('key'), c.strOr('label')),
activities: mine,
));
}
final rate = policy['ugcFeeRate'];
return QuestCatalog(
categories: categories,
feeRate: rate is num ? rate.toDouble() : 0.1,
feeMin: policy.intOr('ugcFeeMin', 10),
feeOnNoReward: policy['ugcFeeOnNoReward'] == true,
maxDays: policy.intOr('ugcMaxDays', 30),
);
}
static String _categoryName(String key, String fallback) => switch (key) {
'health' => appL10n.questCatHealth,
'planet' => appL10n.questCatPlanet,
'community' => appL10n.questCatCommunity,
'location' => appL10n.questTypePlace,
_ => fallback,
};
static String _activityName(String key, String fallback) => switch (key) {
'walk' => appL10n.questTypeSteps,
'shake' => appL10n.questActShake,
'streaming' => appL10n.questTypeStreaming,
'youtube' => 'YouTube',
'vote' => appL10n.questTypeVote,
'sns' => appL10n.questTypeSns,
'community' => appL10n.questCatCommunity,
'location' => appL10n.questTypePlace,
_ => fallback,
};
@override
Future<List<QuestPlace>> places() async {
final snap = await _backend.db.collection('places').get();
return [
for (final d in snap.docs) QuestPlace(id: d.id, name: d.data().strOr('name', d.id)),
];
}
@override
Future<QuestLink> previewLink(String url) async =>
_link(await _backend.call('fnLinkPreview', {'url': url}));
@override
Future<void> submit(String questId, {String? url, String? text}) =>
_backend.call('fnQuestSubmit', {
'questId': questId,
if (url != null && url.isNotEmpty) 'url': url,
if (text != null && text.isNotEmpty) 'text': text,
});
@override
Future<void> review(
String submissionId, {
required bool approve,
String? reason,
bool resubmitAllowed = false,
}) => _backend.call('fnQuestReview', {
'submissionId': submissionId,
'action': approve ? 'approve' : 'reject',
if (reason != null) 'reason': reason,
'resubmitAllowed': resubmitAllowed,
});
@override
Future<void> edit(String questId, {required String title, required String description}) =>
_backend.call('fnQuestEdit', {
'questId': questId,
'title': title,
'description': description,
});
/// 시작·중단은 세션 상태다.
///
/// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여
/// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는
/// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다.
@override
Future<QuestStage> advance(String questId, QuestStage next) async => next;
/// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은
/// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜
/// 23:59:59** 를 서버가 UTC 로 굳히도록 날짜와 그 시각의 UTC 오프셋을 보낸다(C-06).
@override
Future<String> create(QuestDraft draft) async {
final end = draft.endDate;
final offset = end == null
? DateTime.now().timeZoneOffset
: DateTime(end.year, end.month, end.day, 23, 59, 59).timeZoneOffset;
final result = await _backend.call('fnQuestCreate', {
'clientSeq': draft.clientSeq,
'activityKey': draft.activityKey,
'title': draft.title,
'description': draft.description,
'rewardPop': draft.rewardPop,
'capacity': draft.participants,
'utcOffsetMinutes': offset.inMinutes,
if (end != null)
'endDate': '${end.year.toString().padLeft(4, '0')}-'
'${end.month.toString().padLeft(2, '0')}-${end.day.toString().padLeft(2, '0')}',
if (draft.url != null) 'url': draft.url,
if (draft.manualTitle != null) 'manualTitle': draft.manualTitle,
if (draft.placeId != null) 'placeId': draft.placeId,
});
return result.strOr('questId');
}
@override
Stream<int> progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream<int>.empty();
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
return _stepCount().asyncMap((cumulative) async {
_walkSteps = await _baseline.todaySteps(cumulative);
return _walkSteps;
});
}
@override
Future<QuestReward> verify(QuestCard card) async {
final reason = card.type.blockedReason;
if (reason != null) throw BackendException('E-QST-01', reason);
final result = switch (card.type) {
// **퀘스트 문서 ID 를 함께 보낸다.** 서버가 진행 문서를 이 ID 로 쓰고 일일 한도를
// `quests/{questId}.dailyLimit` 에서 읽는다. 보내지 않으면 서버는 유형 이름에
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
QuestType.attend => await _backend.call('fnQuestAttend', {'questId': card.id}),
QuestType.steps => await _verifySteps(card),
QuestType.place => await _verifyPlace(card),
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
QuestType.timeAttack =>
await _backend.call('fnQuestTimeAttack', {'questId': card.id}),
_ => throw BackendException('E-QST-01', card.type.blockedReason ?? ''),
};
return QuestReward(
rewardedPop: result.intOr('rewarded'),
balanceAfter: result.intOr('balanceAfter'),
txId: result.strOr('txId').isEmpty ? null : result.strOr('txId'),
duplicate: result['duplicate'] == true,
);
}
@override
Future<RewardedAdHandle> loadRewardedAd() => loadGoogleRewardedAd();
@override
Future<int> claimAdReward(String impressionId, QuestReward base) async {
final baseTxId = base.txId;
if (baseTxId == null) {
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
}
final result = await _backend.call('fnAdReward', {
'adImpressionId': impressionId,
'baseTxId': baseTxId,
'network': 'admob',
});
return result.intOr('rewarded');
}
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
if (_walkSteps <= 0) {
// 센서 값이 아직 오지 않았다. 0 을 보내면 서버가 미완주로 판정하지만,
// 원인이 「걷지 않았다」가 아니라 「세지 못했다」라 사유가 달라진다.
throw BackendException('E-QST-01', appL10n.questNoSteps);
}
return _backend.call('fnQuestVerifySteps', {
'questId': card.id,
'steps': _walkSteps,
'occurredAt': DateTime.now().toUtc().toIso8601String(),
...await _fandomField(),
});
}
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) async {
final quest = await _backend.db
.doc('quests/${card.id}')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
final placeId = _placeIdOf(quest);
if (placeId.isEmpty) {
// 설계서 §2.8 의 `quests` 에 장소를 가리키는 필드가 없다. 좌표만 보내면
// 서버가 어느 반경과 견줄지 알 수 없으므로 임의로 고르지 않고 끊는다.
throw BackendException('E-QST-03', appL10n.questNoPlace);
}
final fix = await _placeFix();
return _backend.call('fnQuestVerifyPlace', {
// 참여 횟수는 Quest 단위로 센다(정책 원장 C-12 — 같은 장소의 다른 Quest 는 별개).
'questId': card.id,
'placeId': placeId,
'lat': fix.lat,
'lng': fix.lng,
'accuracyM': fix.accuracyM,
// 판정하지 않고 그대로 올린다 — 설계서 §8 「mock location 차단」은 서버 판정이다.
'mocked': fix.mocked,
...await _fandomField(),
});
}
String _placeIdOf(Map<String, dynamic> quest) {
final direct = quest.strOr('placeId');
if (direct.isNotEmpty) return direct;
final conditions = quest['conditions'];
if (conditions is Map) {
return Map<String, dynamic>.from(conditions).strOr('placeId');
}
return '';
}
/// 활동 로그의 집계 축 — `activityLogs.fandomId`(설계서 §2.1). 소속이 없으면 싣지 않는다.
Future<Map<String, dynamic>> _fandomField() async {
final uid = _backend.uid;
if (uid == null) return const {};
final user = await _backend.db
.doc('users/$uid')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
final fandomId = user.strOr('fandomId');
return fandomId.isEmpty ? const {} : {'fandomId': fandomId};
}
}
/// 일일 한도의 리셋 단위 — 서버 `dayKey()` 와 같은 기준(KST)으로 끊는다.
/// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다.
String _dayKey(DateTime at) =>
at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'quest_data.dart';
import '../../core/i18n/app_locale.dart';
/// 보상 완료 — 화면 대조 원장 F4 · Figma `251:113651`(퀘스트 완료) · `482:15084`(광고 시청 후).
///
/// 명세 `518:88630` — *보상 완료 아이콘 · 보상 POP 텍스트 · 중앙 정렬*
/// 명세 `518:88644` — *클릭 시 내 POP + 보유 POP Update ·*
/// *퀘스트 진행 상태값 보상완료 변경 · 퀘스트 상세 화면 이동*
/// 명세 `518:88685` — *광고 시청 POP = 보상 POP × 2 or ×3*
/// 잔액(명세 `518:88644` *보유 POP Update*)은 시안에 표시 자리가 없어 호출부가 다시 읽는다.
///
/// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend`
/// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다.
class QuestRewardScreen extends StatelessWidget {
const QuestRewardScreen({
super.key,
required this.detail,
this.reward,
this.adBonusPop,
});
final QuestDetail detail;
/// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이
/// 다를 수 있고(운영값 조정·등급 배수), 화면이 설정값을 띄우면 원장과 어긋난다.
final QuestReward? reward;
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
final int? adBonusPop;
int? get rewardPop => reward?.rewardedPop ?? detail.card.rewardPop;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
// 시안의 본문 구간(위 56 머리 · 아래 72 버튼 자리) 가운데에 놓는다.
child: Padding(
padding: const EdgeInsets.only(top: 56),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 120 원 둘레에 그림자 6 을 포함한 132 이미지.
Image.asset('assets/figma/quest-done.png', width: 132, height: 132),
const SizedBox(height: 18),
Text(
context.l10n.questRewardDone,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 8),
Text.rich(
TextSpan(
children: [
TextSpan(
text: rewardPop == null ? 'POP' : '+${formatPop(rewardPop!)}',
style: const TextStyle(color: Color(0xFF67D3A9)),
),
TextSpan(text: context.l10n.questRewardReceived),
],
),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
if (adBonusPop != null) ...[
const SizedBox(height: AppSpace.s2),
Text(
context.l10n.questAdBonus(formatPop(adBonusPop!)),
style: t.bodySmall,
textAlign: TextAlign.center,
),
],
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
backgroundColor: AppColors.cosmicAccent,
foregroundColor: Colors.black,
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 24 / 16,
),
),
child: Text(context.l10n.commonDone),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../home/home_screen.dart' show formatPop;
import '../quest_data.dart';
import '../../../core/i18n/app_locale.dart';
/// 퀘스트 대표 이미지 — Figma 사본의 3D 아이콘(`264:46867` 목록 · `264:48835` 상세).
///
/// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 빈 칸이다.
String? questImage(QuestCard card) {
switch (card.type) {
case QuestType.attend:
return 'assets/figma/quest-img-stamp.png';
case QuestType.steps:
return 'assets/figma/quest-img-shoe.png';
case QuestType.place:
return 'assets/figma/quest-img-truck.png';
default:
}
final title = card.title;
if (title.contains('출석')) return 'assets/figma/quest-img-stamp.png';
if (title.contains('좋아요')) return 'assets/figma/quest-img-heart.png';
if (title.contains('댓글')) return 'assets/figma/quest-img-bubble.png';
if (title.contains('만보기') || title.contains('걷기')) {
return 'assets/figma/quest-img-shoe.png';
}
if (title.contains('POP')) return 'assets/figma/quest-img-bolt.png';
return null;
}
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
class QuestIconBox extends StatelessWidget {
const QuestIconBox({super.key, required this.card});
final QuestCard card;
@override
Widget build(BuildContext context) {
final image = questImage(card);
return Container(
width: 50,
height: 50,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFF2E2E2E),
borderRadius: BorderRadius.circular(16),
),
child: image == null ? null : Image.asset(image, width: 42, height: 42),
);
}
}
/// 상태별 버튼 색 — 시안: 보상받기 핑크 · 진행중 #333333 · 시작하기/자세히 흰색 ·
/// 보상 완료 비활성.
({Color bg, Color fg}) questButtonColors(QuestStage stage) => switch (stage) {
QuestStage.completed => (bg: AppColors.cosmicAccent, fg: Colors.black),
QuestStage.inProgress => (
bg: const Color(0xFF333333),
fg: AppColors.onCosmic,
),
QuestStage.rewarded => (
bg: const Color(0xFF333333),
fg: const Color(0xFF777777),
),
_ => (bg: AppColors.onCosmic, fg: Colors.black),
};
/// 목록의 퀘스트 카드 한 장 — 시안 `264:46867` 카드(#1a1a1c r12 · 안쪽 20).
///
/// 명세 — *퀘스트 아이콘 · 퀘스트 명 · 퀘스트 카테고리 · 진행 상태 값 ·*
/// *목표값 · 보상값 · 참여인원값 · 버튼(진행 상태값에 따른 아래 버튼 노출)*.
class QuestCardTile extends StatelessWidget {
const QuestCardTile({
super.key,
required this.card,
required this.onOpen,
required this.onAction,
this.onMore,
this.preview = false,
});
/// 생성 화면 미리보기(`254:164081`) — 버튼이 없고, 비어 있는 값은 회색 「-」다.
const QuestCardTile.preview({super.key, required this.card})
: onOpen = null,
onAction = null,
onMore = null,
preview = true;
final QuestCard card;
final VoidCallback? onOpen;
final VoidCallback? onAction;
final bool preview;
/// 우측 상단 ⋮. 사용자가 만든 퀘스트에만 있다.
final VoidCallback? onMore;
@override
Widget build(BuildContext context) {
final colors = questButtonColors(card.stage);
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QuestIconBox(card: card),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 6),
Text(
card.categoryName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.4,
color: AppColors.onCosmicVariant,
),
),
],
),
),
if (onMore != null) ...[
const SizedBox(width: 10),
GestureDetector(
onTap: onMore,
child: Image.asset(
'assets/figma/quest-kebab.png',
width: 24,
height: 24,
),
),
],
],
),
SizedBox(height: preview ? 16 : 12),
Row(
children: [
Expanded(
child: _Stat(
label: context.l10n.questGoal,
value: card.goal > 0
? '${formatPop(card.progress)} / ${formatPop(card.goal)}'
: '-',
muted: card.goal <= 0,
),
),
const SizedBox(width: 8),
Expanded(
child: _Stat(
label: context.l10n.questReward,
value: (card.rewardPop ?? 0) <= 0 && preview
? '-'
: card.rewardPop == null
? '-'
: formatPop(card.rewardPop!),
unit: 'POP',
muted: preview && (card.rewardPop ?? 0) <= 0,
),
),
],
),
SizedBox(height: preview ? 16 : 12),
Text(
card.capacity == null
? context.l10n.questParticipantsCount(
formatPop(card.participants),
)
: context.l10n.questParticipantsCap(
formatPop(card.participants),
formatPop(card.capacity!),
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: -0.3,
color: preview ? AppColors.onCosmicMuted : AppColors.onCosmic,
),
),
if (!preview) ...[
const SizedBox(height: 16),
FilledButton(
// 「보상완료」는 명세가 클릭 불가로 정했다.
onPressed: card.stage.tappable ? onAction : null,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(37),
padding: const EdgeInsets.symmetric(horizontal: 34),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
backgroundColor: colors.bg,
foregroundColor: colors.fg,
disabledBackgroundColor: colors.bg,
disabledForegroundColor: colors.fg,
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1,
),
),
child: Text(card.stage.buttonLabel),
),
],
],
),
),
),
);
}
}
/// 목표·보상 칸 — #262626 r8 · 안쪽 8/12.
class _Stat extends StatelessWidget {
const _Stat({
required this.label,
required this.value,
this.unit,
this.muted = false,
});
final String label;
final String value;
final String? unit;
/// 아직 값이 없는 칸 — 시안 #545454.
final bool muted;
static const _valueStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 20 / 16,
letterSpacing: -0.5,
color: AppColors.onCosmic,
);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF262626),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 16 / 12,
letterSpacing: -0.5,
color: AppColors.onCosmicVariant,
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: muted
? _valueStyle.copyWith(color: const Color(0xFF545454))
: _valueStyle,
),
),
if (unit != null)
Text(
unit!,
style: _valueStyle.copyWith(color: const Color(0xFF2AC28B)),
),
],
),
],
),
);
}
/// 「오늘 꼭 해야하는 퀘스트에요」 줄 — 시안 `264:46867` 관리자 퀘스트(#1a1a1c · 높이 90).
class QuestAdminRow extends StatelessWidget {
const QuestAdminRow({
super.key,
required this.card,
required this.onOpen,
this.divider = true,
});
final QuestCard card;
final VoidCallback onOpen;
/// 아래 #242424 구분선. 마지막 줄은 없다.
final bool divider;
@override
Widget build(BuildContext context) => Material(
color: AppColors.cosmicSurfaceContainer,
child: InkWell(
onTap: onOpen,
child: Container(
constraints: const BoxConstraints(minHeight: 90),
// 구분선 1 은 Container 가 안쪽 여백에 더하므로 아래에서 뺀다(줄 높이 90).
padding: EdgeInsets.fromLTRB(20, 20, 20, divider ? 19 : 20),
decoration: divider
? const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFF242424))),
)
: null,
child: Row(
children: [
QuestIconBox(card: card),
const SizedBox(width: 19),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
height: 20 / 18,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 4),
Text(
card.categoryName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
],
),
),
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: Color(0xFF4F4F4F),
),
],
),
),
),
);
}
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../../../core/data/server_clock.dart';
/// 타임어택 남은 시간 — 초마다 다시 그린다. 마감은 서버 값(`quests.activeTo`)이라
/// 화면을 다시 열어도 처음부터 세지 않는다. 기준은 [ServerClock] 이다. 마감이 지나면
/// [builder] 가 null 을 받고 타이머를 멈춘다.
///
/// 표시용이다. 참여 수락/거부는 서버 시각으로 다시 판정한다(`fn-quest-timeattack`).
class TimeAttackCountdown extends StatefulWidget {
const TimeAttackCountdown({
super.key,
required this.endsAt,
required this.builder,
});
final DateTime endsAt;
final Widget Function(BuildContext context, Duration? left) builder;
@override
State<TimeAttackCountdown> createState() => _TimeAttackCountdownState();
}
class _TimeAttackCountdownState extends State<TimeAttackCountdown> {
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final left = widget.endsAt.difference(ServerClock.now());
final closed = left.isNegative || left == Duration.zero;
if (closed) _timer.cancel();
return widget.builder(context, closed ? null : left);
}
}
/// `H:MM:SS`(1시간 이상) 또는 `MM:SS`.
String formatLeft(Duration left) {
String two(int n) => n.toString().padLeft(2, '0');
final h = left.inHours;
final m = two(left.inMinutes % 60);
final s = two(left.inSeconds % 60);
return h > 0 ? '$h:$m:$s' : '$m:$s';
}