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