QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/mock_tag.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'campaign_data.dart';
|
||||
import 'widgets/campaign_period_sheet.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
|
||||
/// 새 캠페인 — 화면 대조 원장 F5 · Figma `287:16991`.
|
||||
///
|
||||
/// 명세 `523:18182`(완료 보상 설정 영역) 계열과 같은 구조다 — 입력이 다 차야
|
||||
/// 완료가 열리고, 완료 전에 되묻는다.
|
||||
///
|
||||
/// **AI 사전 검수를 여기서 하지 않는다.** `fn-campaign-ai-precheck` 는 7주차이고,
|
||||
/// 올리면 등록비(B-57)가 빠지고 운영 심사 대기(`review`)로 들어간다(`fn-campaign-create`).
|
||||
/// 화면에 그 사실을 적어 둔다 — 올린 즉시 모금이 시작되는 줄 알면 사용자가 마감을 잘못
|
||||
/// 계산한다.
|
||||
class CampaignCreateScreen extends StatefulWidget {
|
||||
CampaignCreateScreen({super.key, CampaignRepository? repository})
|
||||
: repository = repository ?? SampleCampaignRepository();
|
||||
|
||||
final CampaignRepository repository;
|
||||
|
||||
@override
|
||||
State<CampaignCreateScreen> createState() => _CampaignCreateScreenState();
|
||||
}
|
||||
|
||||
class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
|
||||
final _title = TextEditingController();
|
||||
final _body = TextEditingController();
|
||||
|
||||
/// 지원 종류(Catalog 상품). 고르면 목표 기여 POP · 권장 기간이 정해진다(B-59 · C-14).
|
||||
CampaignProduct? _product;
|
||||
|
||||
/// 모금 기간. 상품을 고르면 오늘부터 권장 일수로 채워지고, 날짜 선택으로 바꾼다.
|
||||
DateTimeRange? _range;
|
||||
bool _saving = false;
|
||||
|
||||
/// 등록비에 쓸 수 있는 잔액(일반 + 기여, B-57). 읽지 못하면 줄을 그리지 않는다.
|
||||
int? _balance;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.repository.feeBalance().then((v) {
|
||||
if (mounted) setState(() => _balance = v);
|
||||
}, onError: (Object _) {});
|
||||
for (final c in [_title, _body]) {
|
||||
c.addListener(() => setState(() {}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_title, _body]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get _goalPop => _product?.goalPop ?? 0;
|
||||
|
||||
int get _days => _range?.duration.inDays ?? 0;
|
||||
|
||||
bool get _ready =>
|
||||
_title.text.trim().isNotEmpty &&
|
||||
_body.text.trim().isNotEmpty &&
|
||||
_product != null &&
|
||||
_days > 0;
|
||||
|
||||
/// 사본 척도 — 목표 막대의 오른쪽 끝 「1,000,000」.
|
||||
static const _scaleMax = 1000000;
|
||||
|
||||
void _pickProduct(CampaignProduct p) {
|
||||
final today = DateUtils.dateOnly(DateTime.now());
|
||||
setState(() {
|
||||
_product = p;
|
||||
_range = DateTimeRange(
|
||||
start: today,
|
||||
end: today.add(Duration(days: p.recommendedDays)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 시안 캘린더 시트(`333:72497` · `333:72719`) — 「기간은 최대 1년까지」.
|
||||
Future<void> _pickRange() async {
|
||||
final r = await showCampaignPeriodSheet(context, initial: _range);
|
||||
if (r != null && mounted) setState(() => _range = r);
|
||||
}
|
||||
|
||||
/// 「2026.4.9 ~ 4.23」 — 해가 바뀌면 끝에도 연도를 붙인다.
|
||||
static String _rangeLabel(DateTimeRange r) {
|
||||
final s = r.start, e = r.end;
|
||||
final end = e.year == s.year
|
||||
? '${e.month}.${e.day}'
|
||||
: '${e.year}.${e.month}.${e.day}';
|
||||
return '${s.year}.${s.month}.${s.day} ~ $end';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const line = Color(0xFF313131);
|
||||
const hint = Color(0xFF6B6B6B);
|
||||
final section = campaignText(
|
||||
20,
|
||||
500,
|
||||
AppColors.onCosmic,
|
||||
height: 28 / 20,
|
||||
spacing: -0.025,
|
||||
);
|
||||
// 시안(287:16991) — 닫기 · 제목 · 완료(비활성 #474747), 미디어 칸 #222 200, 제목(20/600)과
|
||||
// 설명(15/500) 입력, 섹션 제목 20/500, 목표 기여 POP 초록 값 · 보유 POP.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
icon: const ImageIcon(AppIcons.x, size: 24),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(context.l10n.campaignNew),
|
||||
actions: [
|
||||
// G-08 — 지원 종류 가격은 인스타 광고 300,000 외 자리표시다.
|
||||
const MockTag(note: '상품 목록은 Catalog 연결 전 표본입니다'),
|
||||
TextButton(
|
||||
onPressed: _ready && !_saving ? _confirm : null,
|
||||
// 시안 — 오른쪽 끝에서 16(x 335).
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
disabledForegroundColor: const Color(0xFF474747),
|
||||
textStyle: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(24, 56),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(context.l10n.commonDone),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s4),
|
||||
children: [
|
||||
// 사진·영상 등록은 캠페인 문서에 필드가 없어 아직 연결하지 않는다(시안 자리만).
|
||||
Container(
|
||||
height: 200,
|
||||
color: const Color(0xFF222222),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ImageIcon(
|
||||
AssetImage('assets/figma/campaign-camera.png'),
|
||||
size: 16,
|
||||
color: Color(0xFF979B9E),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.campaignPickMedia,
|
||||
style: campaignText(
|
||||
12,
|
||||
400,
|
||||
const Color(0xFF979B9E),
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _title,
|
||||
style: campaignText(20, 600, AppColors.onCosmic, height: 1),
|
||||
decoration: _bare(
|
||||
context.l10n.campaignTitleHint,
|
||||
campaignText(20, 600, hint, height: 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1, thickness: 1, color: line),
|
||||
const SizedBox(height: 12),
|
||||
// 시안 설명 칸 180 안에 검수 안내 한 줄까지 넣는다(아래 간격 24 유지).
|
||||
SizedBox(
|
||||
height: 163,
|
||||
child: TextField(
|
||||
controller: _body,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: campaignText(
|
||||
15,
|
||||
500,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
),
|
||||
decoration: _bare(
|
||||
context.l10n.campaignBodyHint,
|
||||
campaignText(15, 500, hint, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.campaignReviewNote,
|
||||
style: campaignText(
|
||||
12,
|
||||
500,
|
||||
AppColors.onCosmicVariant,
|
||||
height: 17 / 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(context.l10n.campaignSupportType, style: section),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 시안 — 칩 줄은 왼쪽 16 에서 시작해 오른쪽 화면 밖으로 이어진다.
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final p in sampleCampaignCatalog) ...[
|
||||
_productChip(p),
|
||||
if (p != sampleCampaignCatalog.last) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(context.l10n.campaignPeriod, style: section),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 시안 Input — 획 #545454 r8 · 안쪽 16 · 「날짜 선택」 ABeeZee 16 · 달력 20.
|
||||
InkWell(
|
||||
onTap: _pickRange,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF545454)),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_range == null
|
||||
? context.l10n.campaignDateSelect
|
||||
: _rangeLabel(_range!),
|
||||
style: campaignText(
|
||||
16,
|
||||
400,
|
||||
_range == null
|
||||
? const Color(0xFF545454)
|
||||
: AppColors.onCosmic,
|
||||
height: 24 / 16,
|
||||
spacing: 0,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
),
|
||||
ImageIcon(
|
||||
const AssetImage(
|
||||
'assets/figma/campaign-calendar.png',
|
||||
),
|
||||
size: 20,
|
||||
color: _range == null
|
||||
? const Color(0xFF545454)
|
||||
: AppColors.onCosmic,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.campaignGoalPop,
|
||||
style: section,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${formatPop(_goalPop)} POP',
|
||||
style: campaignText(
|
||||
18,
|
||||
500,
|
||||
const Color(0xFF2AC28C),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 시안 슬라이더 자리 — 목표는 상품 등록값 그대로라(B-32) 읽기 전용 막대로 둔다.
|
||||
_goalBar(_goalPop / _scaleMax),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('0', style: _scale),
|
||||
Text(formatPop(_scaleMax), style: _scale),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_balance != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: context.l10n.commonMyPop,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: formatPop(_balance!),
|
||||
style: const TextStyle(color: AppColors.onCosmic),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
AppColors.onCosmicVariant,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _bare(String hint, TextStyle style) => InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: style,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||||
);
|
||||
|
||||
static final _scale = campaignText(
|
||||
12,
|
||||
400,
|
||||
const Color(0xFFCDCDCD),
|
||||
height: 1,
|
||||
spacing: 0,
|
||||
family: AppFonts.display,
|
||||
);
|
||||
|
||||
/// 지원 종류 칩 — 시안 Chip: 알약 #1a1a1a · 획 #232324 · 안쪽 6/16 · 12/500 #d5d7d9.
|
||||
/// 고른 칩은 흰 바탕 검정 글자(`333:73054`).
|
||||
Widget _productChip(CampaignProduct p) {
|
||||
final on = _product == p;
|
||||
return Material(
|
||||
color: on ? AppColors.onCosmic : const Color(0xFF1A1A1A),
|
||||
shape: const StadiumBorder(side: BorderSide(color: Color(0xFF232324))),
|
||||
child: InkWell(
|
||||
onTap: () => _pickProduct(p),
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Text(
|
||||
p.name,
|
||||
style: campaignText(
|
||||
12,
|
||||
500,
|
||||
on ? Colors.black : const Color(0xFFD5D7D9),
|
||||
height: 20 / 12,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 목표 막대 — 시안 `Group 568`: #424242 6 막대 · #2ac28c 채움 · 손잡이 20.
|
||||
Widget _goalBar(double fraction) => Container(
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final f = fraction.clamp(0.0, 1.0);
|
||||
final w = c.maxWidth - 5;
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Container(
|
||||
width: w,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF424242),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: (w - 20) * f + 10,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2AC28C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: (w - 20) * f,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2AC28C),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.campaignSubmitTitle),
|
||||
content: Text(
|
||||
context.l10n.campaignSubmitBody(
|
||||
_title.text.trim(),
|
||||
formatPop(_goalPop),
|
||||
'$_days',
|
||||
formatPop(campaignRegistrationFeePop),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonKeepEditing),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.campaignSubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go != true || !mounted) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
final String id;
|
||||
try {
|
||||
id = await widget.repository.create(
|
||||
CampaignDraft(
|
||||
title: _title.text.trim(),
|
||||
body: _body.text.trim(),
|
||||
goalPop: _goalPop,
|
||||
days: _days,
|
||||
),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
// 자격 · 잔액 부족은 서버 사유를 그대로 보여 준다(E-PERM-01 · E-POP-01).
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
error is BackendException
|
||||
? error.message
|
||||
: context.l10n.campaignCreateFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
// 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다.
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.campaignPendingTitle),
|
||||
content: Text(context.l10n.campaignPendingBody),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
/// 캠페인이 그리는 값의 형태 — 화면 대조 원장 F5.
|
||||
///
|
||||
/// 근거는 Figma 명세 구간이다.
|
||||
/// - 목록 `523:18347`·`523:18388`·`523:18399` · 검색 `523:18417`~`523:18429`
|
||||
/// - 상세 `523:18487`~`523:18589` · 기여 바텀시트 `527:21100`
|
||||
/// - 랭킹 `536:14345`~`536:14382` · 댓글 `527:21192`·`527:21198`
|
||||
library;
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../my/my_data.dart';
|
||||
|
||||
/// 목록 정렬. 명세 — *디폴트 최신순 선택 · 단일 선택*.
|
||||
enum CampaignSort {
|
||||
latest,
|
||||
popular,
|
||||
contributing;
|
||||
|
||||
String get label => switch (this) {
|
||||
latest => appL10n.sortLatest,
|
||||
popular => appL10n.sortPopular,
|
||||
contributing => appL10n.campaignSortContributing,
|
||||
};
|
||||
}
|
||||
|
||||
/// 캠페인 진행 상태. 정책 설계서 §P-09 의 상태 전이를 따른다.
|
||||
enum CampaignStatus {
|
||||
/// 모금 중.
|
||||
funding,
|
||||
|
||||
/// 목표 달성 후 집행.
|
||||
success,
|
||||
|
||||
/// 목표 미달 — 100% 자동 환불 대상.
|
||||
failed,
|
||||
|
||||
/// 결과 보고까지 끝남.
|
||||
reported,
|
||||
|
||||
/// 공개 전 — 초안 · 운영 검수 대기. 개설자에게만 보인다.
|
||||
review,
|
||||
|
||||
/// 운영 반려 — 등록비 반환(B-58).
|
||||
rejected,
|
||||
|
||||
/// 개설자 철회(B-58). 공개 전 철회만 등록비를 돌려받는다.
|
||||
withdrawn,
|
||||
|
||||
/// 운영 취소(회사귀책, B-58) — 등록비 · 후원 전액 반환.
|
||||
cancelled;
|
||||
|
||||
/// 공개 모금 흐름 밖의 상태 안내. 모금 · 결과 상태는 null.
|
||||
String? get notice => switch (this) {
|
||||
review => appL10n.campaignStatusReview,
|
||||
rejected => appL10n.campaignStatusRejected,
|
||||
withdrawn => appL10n.campaignStatusWithdrawn,
|
||||
cancelled => appL10n.campaignStatusCancelled,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// 목록 카드 하나.
|
||||
///
|
||||
/// 명세 `523:18399` — *대표 이미지 · 작성자 · 작성일시 · 달성률(%) · 제목 ·*
|
||||
/// *내용 · 좋아요 · 댓글 · 조회수 · 공유 수*
|
||||
class CampaignCardData {
|
||||
const CampaignCardData({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.authorName,
|
||||
this.summary = '',
|
||||
this.fandomId = '',
|
||||
this.fandomName = '',
|
||||
required this.createdAt,
|
||||
required this.status,
|
||||
required this.goalPop,
|
||||
required this.raisedPop,
|
||||
required this.supporters,
|
||||
required this.likes,
|
||||
required this.comments,
|
||||
required this.views,
|
||||
required this.shares,
|
||||
this.mine = false,
|
||||
this.contributedPop = 0,
|
||||
this.creatorUid,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 개설자 UID — 캠페인 신고 · 개설자 차단의 대상. 없으면 차단할 수 없다.
|
||||
final String? creatorUid;
|
||||
final String title;
|
||||
|
||||
/// 한 줄 소개 — 서버 문서의 `summary`. 공유 문구가 이 값을 쓴다.
|
||||
final String summary;
|
||||
|
||||
final String body;
|
||||
final String authorName;
|
||||
|
||||
/// 소속 팬덤 — 서버 문서의 `fandomId`.
|
||||
final String fandomId;
|
||||
|
||||
/// 행성 이름.
|
||||
///
|
||||
/// **캠페인 문서의 필드가 아니다.** `firestore.rules` 의 `campaigns` 생성 규칙이
|
||||
/// `keys().hasOnly([...])` 로 스키마를 닫아 두었고 그 목록에 `fandomName` 이
|
||||
/// 없다. 비정규화 필드를 넣으면 캠페인 생성 자체가 거부된다. 그래서 이 값은
|
||||
/// `fandoms/{fandomId}.name` 을 읽어 채우는 **파생값**이고, 해석은 캠페인 서버
|
||||
/// 연결 회차가 맡는다. 비어 있으면 공유 문구에서 행성 줄과 팬덤 해시태그가 함께
|
||||
/// 빠진다(정책 원장 B-78 — 고정 3종이 아니다).
|
||||
final String fandomName;
|
||||
final DateTime createdAt;
|
||||
final CampaignStatus status;
|
||||
|
||||
final int goalPop;
|
||||
final int raisedPop;
|
||||
final int supporters;
|
||||
|
||||
final int likes;
|
||||
final int comments;
|
||||
final int views;
|
||||
final int shares;
|
||||
|
||||
/// 내가 만든 캠페인인가. 명세 — *기여하기 버튼은 타인 캠페인에서만 표시*.
|
||||
final bool mine;
|
||||
|
||||
/// 내가 이미 기여한 POP. 「기여중」 필터의 기준이다.
|
||||
final int contributedPop;
|
||||
|
||||
/// 달성률(%). 목표가 0이면 0.
|
||||
int get achievedPercent =>
|
||||
goalPop == 0 ? 0 : ((raisedPop / goalPop) * 100).round();
|
||||
|
||||
/// 인기순 정렬 키. 명세 — *좋아요 > 댓글 > 조회수 > 공유수*.
|
||||
/// 앞의 값이 같을 때만 뒤를 본다는 뜻이라 순서대로 비교한다.
|
||||
int comparePopularity(CampaignCardData other) {
|
||||
for (final pick in <int Function(CampaignCardData)>[
|
||||
(c) => c.likes,
|
||||
(c) => c.comments,
|
||||
(c) => c.views,
|
||||
(c) => c.shares,
|
||||
]) {
|
||||
final d = pick(other).compareTo(pick(this));
|
||||
if (d != 0) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 기여 랭킹 한 줄.
|
||||
class ContributorRank {
|
||||
const ContributorRank({
|
||||
required this.rank,
|
||||
required this.name,
|
||||
required this.pop,
|
||||
this.change = 0,
|
||||
this.isMe = false,
|
||||
});
|
||||
|
||||
final int rank;
|
||||
final String name;
|
||||
final int pop;
|
||||
|
||||
/// 순위 변동값. 양수면 올라갔다.
|
||||
final int change;
|
||||
final bool isMe;
|
||||
}
|
||||
|
||||
/// 랭킹 집계 구간. 명세 `536:14382` — *실시간 · 일간 · 주간*.
|
||||
enum RankWindow {
|
||||
live,
|
||||
daily,
|
||||
weekly;
|
||||
|
||||
String get label => switch (this) {
|
||||
live => appL10n.rankScopeLive,
|
||||
daily => appL10n.rankWindowDaily,
|
||||
weekly => appL10n.rankWindowWeekly,
|
||||
};
|
||||
}
|
||||
|
||||
/// 응원 게시판 댓글 한 장의 크기. 이만큼 오면 「더 보기」가 붙는다.
|
||||
const campaignCommentPageSize = 20;
|
||||
|
||||
/// 응원 게시판 댓글 — 서버 `campaigns/{id}/comments/{cid}` .
|
||||
class CampaignComment {
|
||||
const CampaignComment({
|
||||
required this.id,
|
||||
required this.authorName,
|
||||
required this.body,
|
||||
required this.createdAt,
|
||||
this.authorUid,
|
||||
this.isMine = false,
|
||||
this.likes = 0,
|
||||
this.liked = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 공개 닉네임(없으면 「팬」). 공급자 실명은 쓰지 않는다(M-7).
|
||||
final String authorName;
|
||||
final String body;
|
||||
final DateTime createdAt;
|
||||
|
||||
/// 차단 대상. 없으면 차단할 수 없다.
|
||||
final String? authorUid;
|
||||
final bool isMine;
|
||||
|
||||
/// 좋아요 수(서버 트리거가 센 `likeCount`)와 내가 눌렀는가.
|
||||
final int likes;
|
||||
final bool liked;
|
||||
}
|
||||
|
||||
/// 상세 화면이 필요로 하는 것.
|
||||
class CampaignDetail {
|
||||
const CampaignDetail({
|
||||
required this.card,
|
||||
required this.mediaLabels,
|
||||
required this.endsAt,
|
||||
required this.topContributors,
|
||||
this.liked = false,
|
||||
this.report,
|
||||
});
|
||||
|
||||
final CampaignCardData card;
|
||||
|
||||
/// 결과보고. 게시 전이면 null.
|
||||
final CampaignReport? report;
|
||||
|
||||
/// 내가 이미 좋아요를 눌렀는가. 서버에 저장된 상태에서 시작해야 두 번째 탭이 취소가 된다.
|
||||
final bool liked;
|
||||
|
||||
/// 사진·영상. 자산이 아직 없어 라벨로 둔다. 개수가 인디케이터 수를 정한다.
|
||||
final List<String> mediaLabels;
|
||||
|
||||
final DateTime endsAt;
|
||||
final List<ContributorRank> topContributors;
|
||||
|
||||
/// 남은 시간. 지났으면 [Duration.zero].
|
||||
Duration remaining({DateTime? now}) {
|
||||
final gap = endsAt.difference(now ?? DateTime.now());
|
||||
return gap.isNegative ? Duration.zero : gap;
|
||||
}
|
||||
}
|
||||
|
||||
/// 결과보고 — 요구사항 FR-CMP-06 「집행 사진 · 영수증 · 예산 사용 내역 공개」.
|
||||
/// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(원장 B-35).
|
||||
class CampaignReport {
|
||||
const CampaignReport({
|
||||
required this.body,
|
||||
required this.photoUrls,
|
||||
required this.receiptUrls,
|
||||
required this.budget,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String body;
|
||||
final List<String> photoUrls;
|
||||
final List<String> receiptUrls;
|
||||
final List<({String label, int amount})> budget;
|
||||
final DateTime createdAt;
|
||||
|
||||
int get budgetTotal => budget.fold(0, (sum, l) => sum + l.amount);
|
||||
}
|
||||
|
||||
/// 사용자 캠페인 등록비 — 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다.
|
||||
const int campaignRegistrationFeePop = 100;
|
||||
|
||||
/// Campaign Product Catalog 상품 하나 — 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간).
|
||||
class CampaignProduct {
|
||||
const CampaignProduct(this.name, this.goalPop, this.recommendedDays);
|
||||
|
||||
final String name;
|
||||
|
||||
/// 필요 기여 POP. 목표액은 이 값 그대로다(B-32 — 사용자 임의 입력 없음).
|
||||
final int goalPop;
|
||||
|
||||
/// 권장 모금 기간(일). 날짜 선택에 자동으로 들어간다(C-14).
|
||||
final int recommendedDays;
|
||||
}
|
||||
|
||||
/// MOCK: Catalog 가 앱에 아직 연결되지 않아 사본(`287:16991`) 칩 문구와 표본 수치를 쓴다.
|
||||
/// 인스타 광고 300,000 만 사본 값이고 나머지 POP · 일수는 S03 제안표 확정 전 자리표시다.
|
||||
const sampleCampaignCatalog = [
|
||||
CampaignProduct('인스타 광고', 300000, 14),
|
||||
CampaignProduct('지하철 CM보드', 800000, 30),
|
||||
CampaignProduct('커피차 서포트', 500000, 14),
|
||||
CampaignProduct('커스텀 인이어', 200000, 21),
|
||||
];
|
||||
|
||||
/// 새 캠페인이 만드는 값.
|
||||
class CampaignDraft {
|
||||
const CampaignDraft({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.goalPop,
|
||||
required this.days,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
final int goalPop;
|
||||
final int days;
|
||||
}
|
||||
|
||||
abstract interface class CampaignRepository {
|
||||
/// 명세 — *기본 10개 노출 · 아래 스크롤 시 10개 업데이트*.
|
||||
Future<List<CampaignCardData>> load({
|
||||
CampaignSort sort = CampaignSort.latest,
|
||||
int limit = 10,
|
||||
});
|
||||
|
||||
Future<List<CampaignCardData>> more({required int offset, int limit = 10});
|
||||
|
||||
Future<CampaignDetail> detail(String campaignId);
|
||||
|
||||
Future<List<ContributorRank>> ranking(
|
||||
String campaignId, {
|
||||
RankWindow window = RankWindow.live,
|
||||
});
|
||||
|
||||
Future<List<String>> recentQueries();
|
||||
|
||||
Future<void> clearQueries();
|
||||
|
||||
/// 최근 검색어 한 개 지우기 — 시안 `272:241681` 검색어 칩의 ✕.
|
||||
Future<void> removeQuery(String query);
|
||||
|
||||
Future<List<CampaignCardData>> search(String query);
|
||||
|
||||
/// 기여. **잔액은 서버가 깎는다** — `fn-campaign-fund` 가 원장을 지난다.
|
||||
/// 실패하면 던진다 — 화면은 성공으로 그리지 않는다.
|
||||
Future<int> contribute(String campaignId, int amountPop);
|
||||
|
||||
/// 후원에 쓸 수 있는 잔액 — 기여 POP(정책 원장 B-33).
|
||||
Future<int> balance();
|
||||
|
||||
/// 등록비에 쓸 수 있는 잔액 — 일반 + 기여 POP. 등록비는 겸용이다(B-57 · B-06).
|
||||
Future<int> feeBalance();
|
||||
|
||||
/// 좋아요 저장. 실패하면 던진다.
|
||||
Future<void> like(String campaignId, bool on);
|
||||
|
||||
Future<String> create(CampaignDraft draft);
|
||||
|
||||
/// 내가 만든 캠페인 — 공개 전 · 철회 · 취소 상태까지(MY/내 캠페인).
|
||||
Future<List<CampaignCardData>> mine();
|
||||
|
||||
/// 개설자 철회(`fn-campaign-withdraw` · B-58). 실패하면 던진다.
|
||||
Future<void> withdraw(String campaignId);
|
||||
|
||||
/// 응원 게시판 댓글 한 장 — 최신순(명세 `527:21192`). [more] 가 false 면 첫
|
||||
/// 장부터 다시 읽는다. 차단한 작성자의 댓글은 뺀다.
|
||||
Future<List<CampaignComment>> comments(
|
||||
String campaignId, {
|
||||
bool more = false,
|
||||
});
|
||||
|
||||
/// 댓글 저장. 실패하면 던진다.
|
||||
Future<CampaignComment> addComment(String campaignId, String body);
|
||||
|
||||
Future<void> deleteComment(String campaignId, String commentId);
|
||||
|
||||
/// 신고 — [reason] 은 신고 화면의 사유 문구(`reportReasonCodes` 의 키).
|
||||
Future<void> reportComment(
|
||||
String campaignId,
|
||||
CampaignComment comment,
|
||||
String reason,
|
||||
);
|
||||
|
||||
/// 작성자 차단 — 이후 그 작성자의 댓글은 목록에서 빠진다.
|
||||
Future<void> blockAuthor(CampaignComment comment);
|
||||
|
||||
/// 댓글 좋아요 저장 — 1인 1회. 실패하면 던진다.
|
||||
Future<void> likeComment(String campaignId, String commentId, bool on);
|
||||
|
||||
/// 캠페인 신고 — [reason] 은 신고 화면의 사유 문구. 같은 사람의 재신고는 던진다.
|
||||
Future<void> reportCampaign(String campaignId, String reason);
|
||||
|
||||
/// 개설자 차단 — 이후 그 개설자의 캠페인 · 댓글이 목록에서 빠진다.
|
||||
Future<void> blockCreator(CampaignCardData card);
|
||||
|
||||
/// 댓글 작성 자격 — 공개 닉네임이 있는가(비었거나 이메일 형태면 없음). 규칙도
|
||||
/// 응원 게시판 댓글에는 「팬」 대체값을 받지 않는다.
|
||||
Future<bool> hasPublicNickname();
|
||||
|
||||
/// 닉네임을 정하러 갈 MY 저장소(닉네임 수정 화면이 쓴다).
|
||||
MyRepository get my;
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
class SampleCampaignRepository implements CampaignRepository {
|
||||
SampleCampaignRepository();
|
||||
|
||||
static final _base = DateTime(2026, 9, 1, 12);
|
||||
|
||||
final List<String> _queries = ['강남역', 'LED', '커피차'];
|
||||
|
||||
static final List<CampaignCardData> _cards = [
|
||||
CampaignCardData(
|
||||
id: 'c1',
|
||||
summary: '도심 한가운데에서 함께 축하해요.',
|
||||
fandomId: 'bts',
|
||||
fandomName: 'BTS',
|
||||
title: '강남역 초대형 LED 광고',
|
||||
body: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
|
||||
authorName: '다이브짱',
|
||||
createdAt: _base.subtract(const Duration(hours: 3)),
|
||||
status: CampaignStatus.funding,
|
||||
goalPop: 100000,
|
||||
raisedPop: 92000,
|
||||
supporters: 48,
|
||||
likes: 820,
|
||||
comments: 24,
|
||||
views: 12400,
|
||||
shares: 96,
|
||||
contributedPop: 500,
|
||||
),
|
||||
CampaignCardData(
|
||||
id: 'c2',
|
||||
summary: '무대 뒤에서 고생하는 분들께 시원한 한 잔.',
|
||||
fandomId: 'bts',
|
||||
fandomName: 'BTS',
|
||||
title: '뮤직뱅크 커피차 보내기',
|
||||
body: '무더위에 고생하는 스태프와 아티스트를 위해 커피차를 준비합니다.',
|
||||
authorName: '아미보이',
|
||||
createdAt: _base.subtract(const Duration(days: 2)),
|
||||
status: CampaignStatus.funding,
|
||||
goalPop: 50000,
|
||||
raisedPop: 12000,
|
||||
supporters: 21,
|
||||
likes: 340,
|
||||
comments: 12,
|
||||
views: 5200,
|
||||
shares: 18,
|
||||
),
|
||||
CampaignCardData(
|
||||
id: 'c3',
|
||||
summary: '데뷔일을 2호선 전 역에서.',
|
||||
fandomId: 'newjeans',
|
||||
fandomName: 'NewJeans',
|
||||
title: '지하철 생일 광고',
|
||||
body: '데뷔일에 맞춰 2호선 전 역사에 생일 광고를 겁니다.',
|
||||
authorName: '네누',
|
||||
createdAt: _base.subtract(const Duration(days: 12)),
|
||||
status: CampaignStatus.success,
|
||||
goalPop: 30000,
|
||||
raisedPop: 30000,
|
||||
supporters: 64,
|
||||
likes: 1240,
|
||||
comments: 51,
|
||||
views: 21000,
|
||||
shares: 210,
|
||||
mine: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> load({
|
||||
CampaignSort sort = CampaignSort.latest,
|
||||
int limit = 10,
|
||||
}) async {
|
||||
final list = [..._cards];
|
||||
switch (sort) {
|
||||
case CampaignSort.latest:
|
||||
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
case CampaignSort.popular:
|
||||
list.sort((a, b) => a.comparePopularity(b));
|
||||
case CampaignSort.contributing:
|
||||
list.retainWhere((c) => c.contributedPop > 0);
|
||||
}
|
||||
return list.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> more({
|
||||
required int offset,
|
||||
int limit = 10,
|
||||
}) async => _cards.skip(offset).take(limit).toList();
|
||||
|
||||
@override
|
||||
Future<CampaignDetail> detail(String campaignId) async {
|
||||
final card = _cards.firstWhere(
|
||||
(c) => c.id == campaignId,
|
||||
orElse: () => _cards.first,
|
||||
);
|
||||
return CampaignDetail(
|
||||
card: card,
|
||||
mediaLabels: const ['대표 이미지', '조감도', '위치'],
|
||||
endsAt: _base.add(const Duration(days: 3)),
|
||||
topContributors: const [
|
||||
ContributorRank(rank: 1, name: '버니즈', pop: 12000, change: 2),
|
||||
ContributorRank(rank: 2, name: '캐럿', pop: 9800, change: -1),
|
||||
ContributorRank(rank: 3, name: '나', pop: 500, change: 4, isMe: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ContributorRank>> ranking(
|
||||
String campaignId, {
|
||||
RankWindow window = RankWindow.live,
|
||||
}) async => const [
|
||||
ContributorRank(rank: 1, name: '버니즈', pop: 12000, change: 2),
|
||||
ContributorRank(rank: 2, name: '캐럿', pop: 9800, change: -1),
|
||||
ContributorRank(rank: 3, name: '나', pop: 500, change: 4, isMe: true),
|
||||
ContributorRank(rank: 4, name: '무민', pop: 400),
|
||||
ContributorRank(rank: 5, name: '보라', pop: 320),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<String>> recentQueries() async => [..._queries];
|
||||
|
||||
@override
|
||||
Future<void> clearQueries() async => _queries.clear();
|
||||
|
||||
@override
|
||||
Future<void> removeQuery(String query) async => _queries.remove(query);
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> search(String query) async {
|
||||
// 검색어를 최신순 · 중복 제거로 앞에 쌓는다(명세 `523:18429`).
|
||||
_queries
|
||||
..remove(query)
|
||||
..insert(0, query);
|
||||
return _cards
|
||||
.where((c) => c.title.contains(query) || c.body.contains(query))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> contribute(String campaignId, int amountPop) async => amountPop;
|
||||
|
||||
@override
|
||||
Future<int> balance() async => 12540;
|
||||
|
||||
@override
|
||||
Future<int> feeBalance() => balance();
|
||||
|
||||
@override
|
||||
Future<void> like(String campaignId, bool on) async {}
|
||||
|
||||
@override
|
||||
Future<String> create(CampaignDraft draft) async => 'c-new';
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> mine() async =>
|
||||
(await load(limit: 100)).where((c) => c.mine).toList();
|
||||
|
||||
@override
|
||||
Future<void> withdraw(String campaignId) async {}
|
||||
|
||||
late final List<CampaignComment> _comments = [
|
||||
CampaignComment(
|
||||
id: 'm1',
|
||||
authorName: '아미보이',
|
||||
authorUid: 'u-army',
|
||||
body: '꼭 성공했으면 좋겠어요!',
|
||||
createdAt: _base.subtract(const Duration(minutes: 20)),
|
||||
),
|
||||
CampaignComment(
|
||||
id: 'm2',
|
||||
authorName: '다이브짱',
|
||||
authorUid: 'u-dive',
|
||||
body: '조감도 너무 예쁘네요',
|
||||
createdAt: _base.subtract(const Duration(hours: 5)),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<CampaignComment>> comments(
|
||||
String campaignId, {
|
||||
bool more = false,
|
||||
}) async => more ? const [] : [..._comments];
|
||||
|
||||
@override
|
||||
Future<CampaignComment> addComment(String campaignId, String body) async {
|
||||
final c = CampaignComment(
|
||||
id: 'm${_comments.length + 1}',
|
||||
authorName: appL10n.commonMe,
|
||||
body: body,
|
||||
createdAt: DateTime.now(),
|
||||
isMine: true,
|
||||
);
|
||||
_comments.insert(0, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteComment(String campaignId, String commentId) async =>
|
||||
_comments.removeWhere((c) => c.id == commentId);
|
||||
|
||||
@override
|
||||
Future<void> reportComment(
|
||||
String campaignId,
|
||||
CampaignComment comment,
|
||||
String reason,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> blockAuthor(CampaignComment comment) async =>
|
||||
_comments.removeWhere((c) => c.authorUid == comment.authorUid);
|
||||
|
||||
@override
|
||||
Future<void> likeComment(
|
||||
String campaignId,
|
||||
String commentId,
|
||||
bool on,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> reportCampaign(String campaignId, String reason) async {}
|
||||
|
||||
@override
|
||||
Future<void> blockCreator(CampaignCardData card) async {}
|
||||
|
||||
@override
|
||||
Future<bool> hasPublicNickname() async => true;
|
||||
|
||||
@override
|
||||
late final MyRepository my = SampleMyRepository();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import 'campaign_create_screen.dart';
|
||||
import 'campaign_data.dart';
|
||||
import 'campaign_detail_screen.dart';
|
||||
import 'campaign_search_screen.dart';
|
||||
import 'widgets/campaign_list_card.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
|
||||
/// HOT ARCHIVE — 화면 대조 원장 F5 · Figma `272:241184`.
|
||||
///
|
||||
/// 명세 `523:18327` — *뒤로가기 버튼 홈 화면 이동 · 검색 버튼 아카이브 검색 화면 이동*
|
||||
/// 명세 `523:18347` — *디폴트 최신순 선택 · 단일 선택*
|
||||
/// 명세 `523:18388` — *기본 10개 노출 · 아래 스크롤 시 10개 업데이트*
|
||||
/// 명세 `523:18410` — *새 캠페인 버튼 · 하단 중앙 정렬*
|
||||
class CampaignListScreen extends StatefulWidget {
|
||||
CampaignListScreen({super.key, CampaignRepository? repository})
|
||||
: repository = repository ?? SampleCampaignRepository();
|
||||
|
||||
final CampaignRepository repository;
|
||||
|
||||
@override
|
||||
State<CampaignListScreen> createState() => _CampaignListScreenState();
|
||||
}
|
||||
|
||||
class _CampaignListScreenState extends State<CampaignListScreen> {
|
||||
final _scroll = ScrollController();
|
||||
|
||||
List<CampaignCardData> _cards = const [];
|
||||
CampaignSort _sort = CampaignSort.latest;
|
||||
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 {
|
||||
setState(() => _loading = true);
|
||||
List<CampaignCardData> cards;
|
||||
try {
|
||||
cards = await widget.repository.load(sort: _sort);
|
||||
} on Object {
|
||||
// 불러오지 못하면 빈 목록으로 닫고 알린다. 스피너에 고착되면 고장으로 읽힌다.
|
||||
cards = const [];
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.campaignLoadFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
// 시안(272:241184) — 제목 없는 Top Navigation 54(뒤로 · 검색 42 칸), 그 아래 칩 줄 36.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 54,
|
||||
leadingWidth: 61,
|
||||
leading: const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: BackButton(),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 13),
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.commonSearch,
|
||||
icon: const ImageIcon(AppIcons.search, size: 24),
|
||||
onPressed: _search,
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(36),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final sort in CampaignSort.values) ...[
|
||||
CampaignSortChip(
|
||||
label: sort.label,
|
||||
icon: campaignSortIcon(sort),
|
||||
selected: _sort == sort,
|
||||
onTap: () {
|
||||
setState(() => _sort = sort);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _cards.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_sort == CampaignSort.contributing
|
||||
? context.l10n.campaignEmptyContributing
|
||||
: context.l10n.campaignEmpty,
|
||||
style: t.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 96),
|
||||
itemCount: _cards.length + (_more ? 1 : 0),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s5),
|
||||
itemBuilder: (context, i) {
|
||||
if (i >= _cards.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(AppSpace.s4),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final card = _cards[i];
|
||||
return CampaignListCard(
|
||||
card: card,
|
||||
onOpen: () => _open(card.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: CampaignFab(
|
||||
label: context.l10n.campaignNew,
|
||||
onTap: _create,
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(String id) async {
|
||||
await Navigator.of(context, rootNavigator: true).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CampaignDetailScreen(campaignId: id, repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (mounted) await _load();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final id = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CampaignSearchScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (id != null && mounted) await _open(id);
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final id = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CampaignCreateScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (id != null && mounted) await _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 정렬 칩 아이콘 — 시계 · 하트 · 번개(시안 아이콘 16).
|
||||
Widget campaignSortIcon(CampaignSort sort) => switch (sort) {
|
||||
CampaignSort.latest => const ImageIcon(
|
||||
AssetImage('assets/figma/campaign-clock.png'),
|
||||
),
|
||||
CampaignSort.popular => const Icon(Icons.favorite_border),
|
||||
CampaignSort.contributing => const ImageIcon(
|
||||
AssetImage('assets/figma/icon-bolt.png'),
|
||||
),
|
||||
};
|
||||
|
||||
/// 정렬 칩 — 시안 Chip: 높이 32 · r8 · 안쪽 6/10 · 아이콘 16 · 글자 12/500(줄 20).
|
||||
/// 선택 흰 바탕·검정 글자, 비선택 #1a1a1a · 획 #232324 · #d5d7d9. 테스트가 selected 를 읽는다.
|
||||
class CampaignSortChip extends StatelessWidget {
|
||||
const CampaignSortChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = selected ? Colors.black : const Color(0xFFD5D7D9);
|
||||
return Material(
|
||||
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
side: const BorderSide(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconThemeData(size: 16, color: fg),
|
||||
child: icon,
|
||||
),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
Text(
|
||||
label,
|
||||
style: campaignText(12, 500, fg, height: 20 / 12, spacing: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 하단 가운데 알약 버튼 — 시안 `새 캠페인`: #19191c · 획 #232324 · 안쪽 8/16 · 간격 6 ·
|
||||
/// 핑크 연필 16 · 글자 14/500 #a3a2a0.
|
||||
class CampaignFab extends StatelessWidget {
|
||||
const CampaignFab({super.key, required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: const Color(0xFF19191C),
|
||||
shape: const StadiumBorder(
|
||||
side: BorderSide(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/figma/icon-pencil.png', width: 16, height: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
AppColors.onCosmicVariant,
|
||||
height: 20 / 14,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'campaign_data.dart';
|
||||
import 'widgets/campaign_rank.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
|
||||
/// 기여 랭킹 더보기 — 화면 대조 원장 F5 · Figma `333:70971`.
|
||||
///
|
||||
/// 명세 `536:14361` — *내 순위 · 순위 변동값 · 프로필 · 닉네임 · 기여 POP*
|
||||
/// 명세 `536:14371` — *기여 POP에 따른 랭킹 소팅 · 실시간 데이터 기준*
|
||||
/// 명세 `536:14382` — *랭킹 필터 바텀시트 — 실시간 · 일간 · 주간*
|
||||
class CampaignRankingScreen extends StatefulWidget {
|
||||
CampaignRankingScreen({
|
||||
super.key,
|
||||
required this.campaignId,
|
||||
CampaignRepository? repository,
|
||||
}) : repository = repository ?? SampleCampaignRepository();
|
||||
|
||||
final String campaignId;
|
||||
final CampaignRepository repository;
|
||||
|
||||
@override
|
||||
State<CampaignRankingScreen> createState() => _CampaignRankingScreenState();
|
||||
}
|
||||
|
||||
class _CampaignRankingScreenState extends State<CampaignRankingScreen> {
|
||||
List<ContributorRank> _ranks = const [];
|
||||
RankWindow _window = RankWindow.live;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
final ranks = await widget.repository.ranking(
|
||||
widget.campaignId,
|
||||
window: _window,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ranks = ranks;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
ContributorRank? get _me {
|
||||
for (final r in _ranks) {
|
||||
if (r.isMe) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final me = _me;
|
||||
final sorted = [..._ranks]..sort((a, b) => a.rank.compareTo(b.rank));
|
||||
final top = sorted.take(3).toList();
|
||||
final rest = sorted.skip(3).toList();
|
||||
// 시안(333:70971) — 「USER RANKING」 · 나의 기여 랭킹 카드 · 「실시간 랭킹 ⌄」 필터 · 단상 · 목록.
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('USER RANKING')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
// 내 순위를 맨 위에 고정한다. 목록을 훑지 않아도 보여야 한다.
|
||||
if (me != null) ...[
|
||||
Text(
|
||||
context.l10n.campaignMyRanking,
|
||||
style: campaignText(24, 500, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: RankRow(
|
||||
rank: me,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
],
|
||||
CampaignRankHeader(window: _window, onTap: _pickWindow),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
RankPodium(top: top),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
for (final r in rest) RankRow(rank: r),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickWindow() async {
|
||||
final picked = await showModalBottomSheet<RankWindow>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final w in RankWindow.values)
|
||||
ListTile(
|
||||
title: Text(w.label),
|
||||
trailing: _window == w ? const Icon(Icons.check) : null,
|
||||
onTap: () => Navigator.of(context).pop(w),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.commonCancel),
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _window = picked);
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
/// 「실시간 랭킹 ⌄」 — 18/600 · 아래 꺾쇠 20. 누르면 구간 바텀시트(명세 `536:14382`).
|
||||
class CampaignRankHeader extends StatelessWidget {
|
||||
const CampaignRankHeader({
|
||||
super.key,
|
||||
required this.window,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final RankWindow window;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignWindowRanking(window.label),
|
||||
style: campaignText(18, 600, AppColors.onCosmic, spacing: 0),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const ImageIcon(
|
||||
AssetImage('assets/figma/campaign-chevron-down.png'),
|
||||
size: 20,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../my/my_data.dart' show MyRepository;
|
||||
import '../my/my_repository_firestore.dart';
|
||||
import '../planet/moderation.dart' show reportReasonCodes;
|
||||
import 'campaign_data.dart';
|
||||
|
||||
/// 캠페인의 실 서버 구현 — 화면 대조 원장 F5 (G-03).
|
||||
///
|
||||
/// **읽기는 Firestore, 후원은 `fn-campaign-fund`.** 예전에는 서버가 붙어도 표본이
|
||||
/// 쓰였고 표본의 `contribute` 가 금액을 그대로 돌려줘 화면이 성공처럼 보였다.
|
||||
///
|
||||
/// 읽는 곳 — `firestore.rules` 의 `campaigns` 화이트리스트 필드만 읽는다.
|
||||
/// - 목록·상세 `campaigns/{id}` (공개 상태 `funding·success·fail·report` 만 규칙이 연다)
|
||||
/// - 기여 랭킹 `campaigns/{id}/donations` (`uid` · `amount` · `createdAt`)
|
||||
///
|
||||
/// - 좋아요 `campaigns/{id}/likes/{uid}` (문서 ID = uid 로 중복 차단 · 개수는 집계 쿼리)
|
||||
/// - 응원 게시판 댓글 `campaigns/{id}/comments` (수는 `commentCount` — 서버 트리거가 센다)
|
||||
///
|
||||
/// **서버에 자리가 없어 채우지 못한 것** — 조회수·공유 수(규칙에 필드·경로
|
||||
/// 없음), 다른 후원자의 이름(후원 문서에 `uid` 만 있고 남의 `users` 문서는 못 읽는다).
|
||||
/// 0·빈 목록·중립 이름으로 두고 보고서에 남겼다. 없는 값을 여기서 만들지 않는다.
|
||||
class FirestoreCampaignRepository implements CampaignRepository {
|
||||
FirestoreCampaignRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
/// 규칙 `campaignIsPublic()` 과 같은 목록. 이 필터가 없으면 쿼리 전체가 거부된다.
|
||||
static const _public = ['funding', 'success', 'fail', 'report'];
|
||||
|
||||
final List<String> _queries = [];
|
||||
|
||||
// ponytail: 공개 캠페인을 한 번에 최대 100건 읽어 앱에서 정렬·검색·페이징한다.
|
||||
// 캠페인이 늘면 (status, createdAt DESC) 인덱스 + 커서 페이징으로 옮긴다.
|
||||
Future<List<CampaignCardData>> _all() async {
|
||||
final snap = await _backend.db
|
||||
.collection('campaigns')
|
||||
.where('status', whereIn: _public)
|
||||
.limit(100)
|
||||
.get();
|
||||
final uid = _backend.uid;
|
||||
// 차단한 개설자의 캠페인은 목록 · 검색에서 뺀다.
|
||||
final blocked = uid == null ? const <String>{} : await _blockedUids();
|
||||
return snap.docs
|
||||
.where((d) => !blocked.contains(d.data()['creatorUid']))
|
||||
.map((d) => _card(d.id, d.data(), uid))
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
}
|
||||
|
||||
static CampaignCardData _card(
|
||||
String id,
|
||||
Map<String, dynamic> m,
|
||||
String? uid,
|
||||
) => CampaignCardData(
|
||||
id: id,
|
||||
title: m.strOr('title'),
|
||||
summary: m.strOr('summary'),
|
||||
body: m.strOr('body'),
|
||||
authorName: publicName(m['creatorName']),
|
||||
fandomId: m.strOr('fandomId'),
|
||||
createdAt: m.dateOr('createdAt', DateTime.fromMillisecondsSinceEpoch(0)),
|
||||
status: switch (m.strOr('status')) {
|
||||
'success' => CampaignStatus.success,
|
||||
'fail' || 'failed' => CampaignStatus.failed,
|
||||
'report' => CampaignStatus.reported,
|
||||
'draft' || 'review' => CampaignStatus.review,
|
||||
'rejected' => CampaignStatus.rejected,
|
||||
'withdrawn' => CampaignStatus.withdrawn,
|
||||
'cancelled' => CampaignStatus.cancelled,
|
||||
_ => CampaignStatus.funding,
|
||||
},
|
||||
// 규칙의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다.
|
||||
goalPop: m.intOr('targetPop', m.intOr('goalPop')),
|
||||
raisedPop: m.intOr('raisedPop'),
|
||||
supporters: m.intOr('donorCount'),
|
||||
likes: m.intOr('likes'),
|
||||
comments: m.intOr('commentCount'),
|
||||
views: m.intOr('views'),
|
||||
shares: m.intOr('shares'),
|
||||
mine: uid != null && m['creatorUid'] == uid,
|
||||
creatorUid: m['creatorUid'] is String ? m['creatorUid'] as String : null,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> load({
|
||||
CampaignSort sort = CampaignSort.latest,
|
||||
int limit = 10,
|
||||
}) async {
|
||||
final all = await _all();
|
||||
switch (sort) {
|
||||
case CampaignSort.latest:
|
||||
break;
|
||||
case CampaignSort.popular:
|
||||
all.sort((a, b) => a.comparePopularity(b));
|
||||
case CampaignSort.contributing:
|
||||
// 내 원장에서 찾는다. 후원 문서의 collectionGroup 인덱스는 `donorUid` 로
|
||||
// 걸려 있는데 서버는 `uid` 로 써서 그 경로가 맞지 않는다.
|
||||
final uid = _backend.requireUid;
|
||||
final mine = await _backend.db
|
||||
.collection('users/$uid/popTransactions')
|
||||
.where('reasonCode', isEqualTo: 'CAMPAIGN_FUND')
|
||||
.get();
|
||||
final paths = {for (final d in mine.docs) d.data()['refPath']};
|
||||
all.retainWhere((c) => paths.contains('campaigns/${c.id}'));
|
||||
}
|
||||
return all.take(limit).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> more({
|
||||
required int offset,
|
||||
int limit = 10,
|
||||
}) async => (await _all()).skip(offset).take(limit).toList();
|
||||
|
||||
@override
|
||||
Future<CampaignDetail> detail(String campaignId) async {
|
||||
final snap = await _backend.db.doc('campaigns/$campaignId').get();
|
||||
final data = snap.data();
|
||||
if (data == null) {
|
||||
throw BackendException('E-CMP-03', appL10n.campaignNotFound);
|
||||
}
|
||||
final uid = _backend.uid;
|
||||
final likes = _backend.db.collection('campaigns/$campaignId/likes');
|
||||
final count = (await likes.count().get()).count ?? 0;
|
||||
final liked = uid != null && (await likes.doc(uid).get()).exists;
|
||||
final card = _card(snap.id, {...data, 'likes': count}, uid);
|
||||
return CampaignDetail(
|
||||
card: card,
|
||||
report: card.status == CampaignStatus.reported
|
||||
? await _report(campaignId, card.createdAt)
|
||||
: null,
|
||||
liked: liked,
|
||||
mediaLabels: [appL10n.campaignMediaMain],
|
||||
endsAt: data.dateOr('endsAt', card.createdAt),
|
||||
topContributors: (await ranking(campaignId)).take(3).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 결과보고 — 파일은 Storage 경로로 저장돼 있어 표시용 URL 로 바꾼다
|
||||
/// (`storage.rules` 의 `campaignReports/` 는 공개 읽기).
|
||||
Future<CampaignReport?> _report(String campaignId, DateTime fallback) async {
|
||||
final m =
|
||||
(await _backend.db.doc('campaigns/$campaignId/reports/main').get())
|
||||
.data();
|
||||
if (m == null) return null;
|
||||
Future<List<String>> urls(String key) => Future.wait([
|
||||
for (final p in (m[key] as List? ?? const []))
|
||||
if (p is String) _backend.storage.ref(p).getDownloadURL(),
|
||||
]);
|
||||
return CampaignReport(
|
||||
body: m.strOr('body'),
|
||||
photoUrls: await urls('photoPaths'),
|
||||
receiptUrls: await urls('receiptPaths'),
|
||||
budget: [
|
||||
for (final l in (m['budget'] as List? ?? const []))
|
||||
if (l is Map<String, dynamic>)
|
||||
(label: l.strOr('label'), amount: l.intOr('amount')),
|
||||
],
|
||||
createdAt: m.dateOr('createdAt', fallback),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ContributorRank>> ranking(
|
||||
String campaignId, {
|
||||
RankWindow window = RankWindow.live,
|
||||
}) async {
|
||||
final snap = await _backend.db
|
||||
.collection('campaigns/$campaignId/donations')
|
||||
.get();
|
||||
final since = switch (window) {
|
||||
RankWindow.live => null,
|
||||
RankWindow.daily => DateTime.now().subtract(const Duration(days: 1)),
|
||||
RankWindow.weekly => DateTime.now().subtract(const Duration(days: 7)),
|
||||
};
|
||||
final totals = <String, int>{};
|
||||
for (final d in snap.docs) {
|
||||
final m = d.data();
|
||||
if (since != null && m.dateOr('createdAt', since).isBefore(since))
|
||||
continue;
|
||||
final uid = m.strOr('uid');
|
||||
totals[uid] = (totals[uid] ?? 0) + m.intOr('amount');
|
||||
}
|
||||
final me = _backend.uid;
|
||||
final rows = totals.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
return [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
ContributorRank(
|
||||
rank: i + 1,
|
||||
// 남의 이름은 서버에 읽을 자리가 없다. UID 는 화면에 내보내지 않는다.
|
||||
name: rows[i].key == me ? appL10n.commonMe : appL10n.commonFan,
|
||||
pop: rows[i].value,
|
||||
isMe: rows[i].key == me,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> recentQueries() async => [..._queries];
|
||||
|
||||
@override
|
||||
Future<void> clearQueries() async => _queries.clear();
|
||||
|
||||
@override
|
||||
Future<void> removeQuery(String query) async => _queries.remove(query);
|
||||
|
||||
@override
|
||||
Future<List<CampaignCardData>> search(String query) async {
|
||||
_queries
|
||||
..remove(query)
|
||||
..insert(0, query);
|
||||
return (await _all())
|
||||
.where((c) => c.title.contains(query) || c.body.contains(query))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 아직 성공이 확인되지 않은 후원 시도의 요청 번호 — `캠페인 · 금액` 별.
|
||||
///
|
||||
/// 응답이 유실된 뒤 다시 누르면 **같은 번호**로 보내야 서버 멱등 키가 두 번째 차감을 막는다.
|
||||
/// 탭마다 새 번호를 만들면 첫 요청이 실제로는 성공했을 때 이중 후원이 된다.
|
||||
final Map<String, String> _pendingSeq = {};
|
||||
|
||||
/// 후원 — `fn-campaign-fund`. 기여 POP 만 쓴다(정책 원장 B-33).
|
||||
/// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게.
|
||||
@override
|
||||
Future<int> contribute(String campaignId, int amountPop) async {
|
||||
final key = '$campaignId:$amountPop';
|
||||
final seq = _pendingSeq[key] ??= _backend.db.collection('_').doc().id;
|
||||
await _backend.call('fnCampaignFund', {
|
||||
'campaignId': campaignId,
|
||||
'clientSeq': seq,
|
||||
'amountPop': amountPop,
|
||||
});
|
||||
// 성공이 확인된 뒤에만 번호를 버린다 — 다음 후원은 새 시도다.
|
||||
_pendingSeq.remove(key);
|
||||
return amountPop;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> balance() async {
|
||||
final uid = _backend.requireUid;
|
||||
final wallet = await _backend.db.doc('users/$uid/wallet/main').get();
|
||||
return (wallet.data() ?? const {}).intOr('supportBalance');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> feeBalance() async {
|
||||
final uid = _backend.requireUid;
|
||||
final wallet =
|
||||
(await _backend.db.doc('users/$uid/wallet/main').get()).data() ??
|
||||
const {};
|
||||
return wallet.intOr('activityBalance') + wallet.intOr('supportBalance');
|
||||
}
|
||||
|
||||
/// 좋아요 — `campaigns/{id}/likes/{uid}` 를 만들거나 지운다(라운지 좋아요와 같은 구조).
|
||||
/// 실패는 그대로 올린다 — 화면은 저장이 끝난 뒤에만 숫자를 바꾼다.
|
||||
@override
|
||||
Future<void> like(String campaignId, bool on) async {
|
||||
final ref = _backend.db.doc(
|
||||
'campaigns/$campaignId/likes/${_backend.requireUid}',
|
||||
);
|
||||
if (on) {
|
||||
await ref.set({'createdAt': FieldValue.serverTimestamp()});
|
||||
} else {
|
||||
await ref.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// 규칙이 개설자 본인 문서는 상태와 무관하게 읽기를 허용한다(`creatorUid == auth.uid`).
|
||||
@override
|
||||
Future<List<CampaignCardData>> mine() async {
|
||||
final uid = _backend.requireUid;
|
||||
final snap = await _backend.db
|
||||
.collection('campaigns')
|
||||
.where('creatorUid', isEqualTo: uid)
|
||||
.get();
|
||||
return snap.docs.map((d) => _card(d.id, d.data(), uid)).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> withdraw(String campaignId) async {
|
||||
await _backend.call('fnCampaignWithdraw', {'campaignId': campaignId});
|
||||
}
|
||||
|
||||
/// 개설 — `fn-campaign-create`. 서버가 자격(B-56)을 보고 등록비(B-57)를 뺀 뒤
|
||||
/// 심사 대기(`review`)로 올린다. 규칙은 클라이언트 직접 생성을 막는다.
|
||||
/// 응답이 유실된 재시도는 같은 요청 번호로 보내 등록비가 두 번 빠지지 않게 한다.
|
||||
@override
|
||||
Future<String> create(CampaignDraft draft) async {
|
||||
final key = 'create:${draft.title}:${draft.goalPop}:${draft.days}';
|
||||
final seq = _pendingSeq[key] ??= _backend.db.collection('_').doc().id;
|
||||
final result = await _backend.call('fnCampaignCreate', {
|
||||
'clientSeq': seq,
|
||||
'title': draft.title,
|
||||
'body': draft.body,
|
||||
'targetPop': draft.goalPop,
|
||||
'days': draft.days,
|
||||
});
|
||||
_pendingSeq.remove(key);
|
||||
return result.strOr('campaignId');
|
||||
}
|
||||
|
||||
QueryDocumentSnapshot<Map<String, dynamic>>? _commentCursor;
|
||||
|
||||
/// 내가 차단한 UID. 목록 · 댓글 장을 읽을 때마다 새로 읽는다 — MY 에서 차단을
|
||||
/// 풀거나 다른 화면에서 차단해도 저장소 캐시가 낡지 않는다.
|
||||
Future<Set<String>> _blockedUids() async => {
|
||||
for (final d
|
||||
in (await _backend.db
|
||||
.collection('users/${_backend.requireUid}/blocks')
|
||||
.get())
|
||||
.docs)
|
||||
d.id,
|
||||
};
|
||||
|
||||
/// 최신순 한 장. 차단한 작성자의 댓글이 한 장을 다 채우면 화면이 「끝」으로
|
||||
/// 오인하므로 서버에 댓글이 남아 있는 동안 다음 장을 더 읽는다(라운지와 같은 방식).
|
||||
@override
|
||||
Future<List<CampaignComment>> comments(
|
||||
String campaignId, {
|
||||
bool more = false,
|
||||
}) async {
|
||||
if (!more) _commentCursor = null;
|
||||
final uid = _backend.requireUid;
|
||||
final blocked = await _blockedUids();
|
||||
final out = <CampaignComment>[];
|
||||
while (out.length < campaignCommentPageSize) {
|
||||
var query = _backend.db
|
||||
.collection('campaigns/$campaignId/comments')
|
||||
.where('visibility', isEqualTo: 'visible')
|
||||
.orderBy('createdAt', descending: true);
|
||||
final cursor = _commentCursor;
|
||||
if (cursor != null) query = query.startAfterDocument(cursor);
|
||||
final snap = await query.limit(campaignCommentPageSize).get();
|
||||
if (snap.docs.isNotEmpty) _commentCursor = snap.docs.last;
|
||||
out.addAll(
|
||||
await Future.wait([
|
||||
for (final d in snap.docs)
|
||||
if (!blocked.contains(d.data()['authorUid'])) _comment(d, uid),
|
||||
]),
|
||||
);
|
||||
if (snap.docs.length < campaignCommentPageSize) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// ponytail: 댓글마다 내 좋아요 문서를 한 번씩 읽는다(한 장 20건). 무거워지면
|
||||
/// 좋아요한 댓글 ID 를 한 번의 collectionGroup 질의로 모은다.
|
||||
static Future<CampaignComment> _comment(
|
||||
DocumentSnapshot<Map<String, dynamic>> d,
|
||||
String uid,
|
||||
) async {
|
||||
final data = d.data() ?? const <String, dynamic>{};
|
||||
final author = data['authorUid'];
|
||||
final liked = (await d.reference.collection('likes').doc(uid).get()).exists;
|
||||
return CampaignComment(
|
||||
likes: data.intOr('likeCount'),
|
||||
liked: liked,
|
||||
id: d.id,
|
||||
authorName: publicName(data['authorName']),
|
||||
authorUid: author is String ? author : null,
|
||||
body: data.strOr('body'),
|
||||
createdAt: data.dateOr('createdAt', DateTime.now()),
|
||||
isMine: author == uid,
|
||||
);
|
||||
}
|
||||
|
||||
/// 작성자 표시명 = 공개 닉네임(없으면 언어 무관 「팬」 — [storedPublicName]).
|
||||
/// 공급자 실명(`displayName`)은 쓰지 않는다(M-7). 규칙 `authorNameIsPublic()` 이 같은 계산으로 대조한다.
|
||||
@override
|
||||
Future<CampaignComment> addComment(String campaignId, String body) async {
|
||||
final uid = _backend.requireUid;
|
||||
final name = storedPublicName(
|
||||
(await _backend.db.doc('users/$uid').get()).data()?['nickname'],
|
||||
);
|
||||
final ref = await _backend.db
|
||||
.collection('campaigns/$campaignId/comments')
|
||||
.add({
|
||||
'authorUid': uid,
|
||||
'authorName': name,
|
||||
'body': body,
|
||||
'visibility': 'visible',
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
return _comment(await ref.get(), uid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteComment(String campaignId, String commentId) =>
|
||||
_backend.db.doc('campaigns/$campaignId/comments/$commentId').delete();
|
||||
|
||||
/// 댓글 좋아요 — `comments/{cid}/likes/{uid}`. 수는 서버 트리거가 센다.
|
||||
@override
|
||||
Future<void> likeComment(String campaignId, String commentId, bool on) async {
|
||||
final ref = _backend.db.doc(
|
||||
'campaigns/$campaignId/comments/$commentId/likes/${_backend.requireUid}',
|
||||
);
|
||||
if (on) {
|
||||
await ref.set({'createdAt': FieldValue.serverTimestamp()});
|
||||
} else {
|
||||
await ref.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// 신고 — 라운지 글과 같은 `reports` 스키마. 문서 ID 를 「대상(댓글은 캠페인 + 댓글) + 신고자」로
|
||||
/// 고정해 규칙이 재접수(덮어쓰기)를 거부한다(B-45 「신고자 수」).
|
||||
@override
|
||||
Future<void> reportComment(
|
||||
String campaignId,
|
||||
CampaignComment comment,
|
||||
String reason,
|
||||
) => _fileReport(
|
||||
'campaignComment',
|
||||
'$campaignId-${comment.id}',
|
||||
'campaigns/$campaignId/comments/${comment.id}',
|
||||
reason,
|
||||
);
|
||||
|
||||
/// 캠페인 신고 — ID `campaign-{캠페인}-{신고자}`.
|
||||
@override
|
||||
Future<void> reportCampaign(String campaignId, String reason) =>
|
||||
_fileReport('campaign', campaignId, 'campaigns/$campaignId', reason);
|
||||
|
||||
Future<void> _fileReport(
|
||||
String type,
|
||||
String idPart,
|
||||
String targetPath,
|
||||
String reason,
|
||||
) async {
|
||||
final uid = _backend.requireUid;
|
||||
final code = reportReasonCodes[reason];
|
||||
if (code == null) {
|
||||
throw BackendException('E-PERM-01', appL10n.reportReasonUnknown);
|
||||
}
|
||||
try {
|
||||
await _backend.db.doc('reports/$type-$idPart-$uid').set({
|
||||
'reporterUid': uid,
|
||||
'targetType': type,
|
||||
'targetPath': targetPath,
|
||||
'reasonCode': code,
|
||||
'status': 'pending',
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
} on FirebaseException catch (error) {
|
||||
if (error.code == 'permission-denied') {
|
||||
throw BackendException('E-PERM-01', appL10n.reportAlready);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasPublicNickname() async {
|
||||
final user = await _backend.db.doc('users/${_backend.requireUid}').get();
|
||||
return publicName(user.data()?['nickname'], '').isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
late final MyRepository my = FirestoreMyRepository(_backend);
|
||||
|
||||
/// 차단 — MY 의 차단 목록과 같은 경로 · 같은 메서드를 쓴다.
|
||||
@override
|
||||
Future<void> blockAuthor(CampaignComment comment) =>
|
||||
_block(comment.authorUid, comment.authorName);
|
||||
|
||||
/// 개설자 차단 — 댓글 작성자 차단과 같은 목록이라 그 개설자의 댓글도 빠진다.
|
||||
@override
|
||||
Future<void> blockCreator(CampaignCardData card) =>
|
||||
_block(card.creatorUid, card.authorName);
|
||||
|
||||
Future<void> _block(String? target, String nickname) async {
|
||||
if (target == null) {
|
||||
throw BackendException('E-PERM-01', appL10n.blockUnavailable);
|
||||
}
|
||||
await my.block(target, nickname: nickname);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/play_banner.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import 'campaign_create_screen.dart';
|
||||
import 'campaign_data.dart';
|
||||
import 'campaign_list_screen.dart';
|
||||
import 'widgets/campaign_list_card.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
|
||||
/// 아카이브 검색 — 화면 대조 원장 F5.
|
||||
///
|
||||
/// Figma 노드 `272:241681`(검색) · `272:241724`(검색 결과).
|
||||
/// **두 화면을 하나로 둔다** — Figma 가 프레임을 나눠 두었으나 사용자에게는
|
||||
/// 같은 화면의 두 상태이며, 따로 만들면 검색어 입력창이 두 벌이 된다.
|
||||
///
|
||||
/// 명세 `523:18429` — *가장 최근 검색 결과 데이터가 첫번째 위치 · 최신순 노출 및*
|
||||
/// *중복 제거 · 데이터가 없을 경우 「최신 검색어가 없습니다」 노출*
|
||||
///
|
||||
/// 검색 결과 시안은 목록과 같은 정렬 칩 · 카드 · 「새 캠페인」 버튼을 쓴다.
|
||||
/// 명세 `523:18422` 의 광고 배너(AdMob Native)는 SDK 미도입이라 그리지 않는다.
|
||||
class CampaignSearchScreen extends StatefulWidget {
|
||||
CampaignSearchScreen({super.key, CampaignRepository? repository})
|
||||
: repository = repository ?? SampleCampaignRepository();
|
||||
|
||||
final CampaignRepository repository;
|
||||
|
||||
@override
|
||||
State<CampaignSearchScreen> createState() => _CampaignSearchScreenState();
|
||||
}
|
||||
|
||||
class _CampaignSearchScreenState extends State<CampaignSearchScreen> {
|
||||
final _query = TextEditingController();
|
||||
|
||||
List<String> _recent = const [];
|
||||
List<CampaignCardData>? _results;
|
||||
List<CampaignCardData> _sorted = const [];
|
||||
CampaignSort _sort = CampaignSort.latest;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRecent();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadRecent() async {
|
||||
final recent = await widget.repository.recentQueries();
|
||||
if (mounted) setState(() => _recent = recent);
|
||||
}
|
||||
|
||||
Future<void> _search(String raw) async {
|
||||
final query = raw.trim();
|
||||
if (query.isEmpty) return;
|
||||
_query.text = query;
|
||||
final results = await widget.repository.search(query);
|
||||
if (!mounted) return;
|
||||
setState(() => _results = results);
|
||||
await _applySort(_sort);
|
||||
await _loadRecent();
|
||||
}
|
||||
|
||||
/// 결과 안에서 정렬한다. 「기여중」은 내 후원 원장을 아는 저장소 목록과 겹쳐 본다.
|
||||
Future<void> _applySort(CampaignSort sort) async {
|
||||
final results = _results ?? const <CampaignCardData>[];
|
||||
var list = [...results];
|
||||
switch (sort) {
|
||||
case CampaignSort.latest:
|
||||
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
case CampaignSort.popular:
|
||||
list.sort((a, b) => a.comparePopularity(b));
|
||||
case CampaignSort.contributing:
|
||||
final mine = {
|
||||
for (final c in await widget.repository.load(sort: sort)) c.id,
|
||||
};
|
||||
list = list.where((c) => mine.contains(c.id)).toList();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sort = sort;
|
||||
_sorted = list;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final results = _results;
|
||||
// 시안 — 뒤로 42 칸 옆에 #282828 r10 검색 칸(높이 42 · 돋보기 20 · 글자 16/500).
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 54,
|
||||
leadingWidth: 58,
|
||||
titleSpacing: 0,
|
||||
leading: const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: BackButton(),
|
||||
),
|
||||
title: Container(
|
||||
height: 42,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF282828),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const ImageIcon(
|
||||
AppIcons.search,
|
||||
size: 20,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _query,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
style: campaignText(
|
||||
16,
|
||||
500,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.campaignSearchHint,
|
||||
hintStyle: campaignText(
|
||||
16,
|
||||
500,
|
||||
AppColors.onCosmicMuted,
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onSubmitted: _search,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: const [SizedBox(width: 16)],
|
||||
bottom: results == null
|
||||
? null
|
||||
: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(36),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final sort in CampaignSort.values) ...[
|
||||
CampaignSortChip(
|
||||
label: sort.label,
|
||||
icon: campaignSortIcon(sort),
|
||||
selected: _sort == sort,
|
||||
onTap: () => _applySort(sort),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: results == null ? _recentView() : _resultView(),
|
||||
floatingActionButton: results == null
|
||||
? null
|
||||
: CampaignFab(label: context.l10n.campaignNew, onTap: _create),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 `272:241681` — 배너만 좌우 9(357 폭), 나머지는 16 · 배너 아래 24.
|
||||
Widget _recentView() => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(9, 16, 9, 16),
|
||||
children: [
|
||||
const PlayBanner(),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
child: _recentBody(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _recentBody() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.commonRecentSearches,
|
||||
style: campaignText(
|
||||
16,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 20 / 16,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
if (_recent.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
await widget.repository.clearQueries();
|
||||
await _loadRecent();
|
||||
},
|
||||
child: Text(
|
||||
context.l10n.commonClearAll,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
const Color(0xFFC2C2C2),
|
||||
height: 20 / 14,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
if (_recent.isEmpty)
|
||||
Text(
|
||||
context.l10n.campaignNoRecentSearches,
|
||||
style: campaignText(14, 500, AppColors.onCosmicVariant),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [for (final q in _recent) _queryChip(q)],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// 최근 검색어 칩 — 획 #414141 알약 · 안쪽 6/14 · 글자 15/500 #c2c2c2 · ✕ 18.
|
||||
Widget _queryChip(String q) => Material(
|
||||
color: Colors.transparent,
|
||||
shape: const StadiumBorder(side: BorderSide(color: Color(0xFF414141))),
|
||||
child: InkWell(
|
||||
onTap: () => _search(q),
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
q,
|
||||
style: campaignText(
|
||||
15,
|
||||
500,
|
||||
const Color(0xFFC2C2C2),
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
InkResponse(
|
||||
onTap: () async {
|
||||
await widget.repository.removeQuery(q);
|
||||
await _loadRecent();
|
||||
},
|
||||
radius: 14,
|
||||
child: Tooltip(
|
||||
message: context.l10n.searchRemoveTerm(q),
|
||||
child: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 18,
|
||||
color: Color(0xFFC2C2C2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _resultView() {
|
||||
if (_sorted.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
context.l10n.campaignNoResults,
|
||||
style: campaignText(14, 500, AppColors.onCosmicVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 96),
|
||||
itemCount: _sorted.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s5),
|
||||
itemBuilder: (context, i) => CampaignListCard(
|
||||
card: _sorted[i],
|
||||
onOpen: () => Navigator.of(context).pop(_sorted[i].id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final id = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CampaignCreateScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (id != null && mounted) await _search(_query.text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/// 캠페인 공유 문구 조립 — 「홈/HOT ARCHIVE/공유」 · Figma `480:14594`.
|
||||
///
|
||||
/// 조립을 화면 밖에 둔 이유는 정책 원장 **B-78** 이다. 해시태그를 콘텐츠에 따라
|
||||
/// 동적으로 구성하라고 확정했고 검증 기준이 *고정 3종 하드코딩 0건* 이므로,
|
||||
/// 규칙을 한 곳에 두고 테스트로 고정한다. 화면 안에 문자열을 이어 붙이면
|
||||
/// 캠페인과 퀘스트에서 규칙이 갈린다.
|
||||
library;
|
||||
|
||||
import '../../core/data/invite_code.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
const String shareBrand = 'FANUZU';
|
||||
|
||||
/// 웹 브릿지 주소 — 빌드 설정(`--dart-define=FANUZU_SHARE_BASE_URL=https://…`).
|
||||
///
|
||||
/// 도메인은 해석 확인 요청 「공유 · 웹 브릿지」 3-1 회신 뒤 정해진다. 설정이 없으면 링크 없이
|
||||
/// 문구만 보낸다 — 살아 있지 않은 주소를 사용자 손에 쥐여 주지 않는다.
|
||||
const String shareBaseUrl = String.fromEnvironment('FANUZU_SHARE_BASE_URL');
|
||||
|
||||
/// 공유 링크 한 곳 — 경로는 프로토타입 구조(`/b/<팬덤>/<날짜>` · `/c/<캠페인>`)를 따른다.
|
||||
/// 브리핑 · 캠페인(HOT ARCHIVE) 공유가 모두 여기를 지난다. 내 초대 코드([InviteCode])를
|
||||
/// 받아 두었으면 `?ref=<코드>` 를 붙이고, 없으면 붙이지 않는다(공유를 막지 않는다).
|
||||
String? shareLink(
|
||||
List<String> segments, {
|
||||
String base = shareBaseUrl,
|
||||
String? ref,
|
||||
}) {
|
||||
if (base.isEmpty || segments.any((s) => s.isEmpty)) return null;
|
||||
final root = base.endsWith('/') ? base.substring(0, base.length - 1) : base;
|
||||
final code = ref ?? InviteCode.current;
|
||||
final query = code == null || code.isEmpty
|
||||
? ''
|
||||
: '?ref=${Uri.encodeQueryComponent(code)}';
|
||||
return '$root/${segments.map(Uri.encodeComponent).join('/')}$query';
|
||||
}
|
||||
|
||||
/// 캠페인 상세로 돌아오는 링크 `/c/<campaignId>`.
|
||||
String? campaignShareLink(String campaignId, {String base = shareBaseUrl}) =>
|
||||
shareLink(['c', campaignId], base: base);
|
||||
|
||||
/// 해시태그를 조립한다. 최대 [maxHashtags] 개다.
|
||||
///
|
||||
/// B-78 — *브랜드 · Artist/Fandom · Campaign/Quest 등을 콘텐츠에 따라 최대 약
|
||||
/// 3 개로 동적 구성합니다.* 세 자리를 채우는 것이 아니라 **있는 것만** 넣는다.
|
||||
/// 팬덤을 모르는 캠페인은 두 개가 되고, 그것이 「고정 3종」이 아니라는 증거다.
|
||||
List<String> shareHashtags({
|
||||
String brand = shareBrand,
|
||||
String? fandomName,
|
||||
String? topic,
|
||||
int maxHashtags = 3,
|
||||
}) {
|
||||
final tags = <String>[
|
||||
for (final value in [brand, fandomName, topic])
|
||||
if (value != null && value.trim().isNotEmpty) '#${_tag(value)}',
|
||||
];
|
||||
return tags.take(maxHashtags).toList();
|
||||
}
|
||||
|
||||
/// 해시태그 한 조각. 공백과 해시는 태그를 끊으므로 지운다.
|
||||
String _tag(String value) => value.trim().replaceAll(RegExp(r'[\s#]+'), '');
|
||||
|
||||
/// 캠페인 공유 문구.
|
||||
///
|
||||
/// 제목과 한 줄 소개, 행성명, 링크(있을 때), 해시태그로 끝난다. 소개가 비어 있으면 그 줄을
|
||||
/// 빼고, 행성명을 모르면 그 줄과 팬덤 해시태그가 함께 빠진다.
|
||||
String campaignShareText({
|
||||
required String title,
|
||||
String? summary,
|
||||
String? fandomName,
|
||||
String? topic,
|
||||
String? link,
|
||||
}) {
|
||||
topic ??= appL10n.shareTopicCampaign;
|
||||
final lines = <String>[
|
||||
title.trim(),
|
||||
if (summary != null && summary.trim().isNotEmpty) summary.trim(),
|
||||
if (fandomName != null && fandomName.trim().isNotEmpty)
|
||||
appL10n.sharePlanetLine(shareBrand, fandomName.trim()),
|
||||
?link,
|
||||
shareHashtags(fandomName: fandomName, topic: topic).join(' '),
|
||||
];
|
||||
return lines.where((l) => l.isNotEmpty).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 캠페인 기여률 카드 — 시안 `272:245738`(상세) · `480:14594`(공유) 공용.
|
||||
///
|
||||
/// #1a1a1c r12 안쪽 12/16. 라벨 12/500 · 「92%」(ABeeZee 32 + % 20) · 10 두께 막대
|
||||
/// (바탕 #000 30%, 채움 그라디언트) · 0 / 목표. 채움 끝 위에 누적 POP 말풍선(100 폭)이 뜬다.
|
||||
class CampaignGauge extends StatelessWidget {
|
||||
const CampaignGauge({super.key, required this.raised, required this.goal});
|
||||
|
||||
final int raised;
|
||||
final int goal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = goal == 0 ? 0.0 : (raised / goal).clamp(0.0, 1.0);
|
||||
final percent = goal == 0 ? 0 : ((raised / goal) * 100).round();
|
||||
final axis = campaignText(
|
||||
12,
|
||||
400,
|
||||
const Color(0xFFCDCDCD),
|
||||
height: 1,
|
||||
spacing: 0,
|
||||
family: AppFonts.display,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final fill = box.maxWidth * ratio;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignGaugeLabel,
|
||||
style: campaignText(
|
||||
12,
|
||||
500,
|
||||
AppColors.onCosmicVariant,
|
||||
height: 20 / 12,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: '$percent',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '%',
|
||||
style: TextStyle(fontSize: 20, letterSpacing: -0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: campaignText(
|
||||
32,
|
||||
400,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: fill,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
gradient: campaignGradient,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('0', style: axis),
|
||||
Text(formatPop(goal), style: axis),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 31.5,
|
||||
left: (fill - 50).clamp(0.0, box.maxWidth - 100),
|
||||
child: _Bubble(label: formatPop(raised)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 누적 POP 말풍선 — 그라디언트 알약 100×32 + 아래 꼬리.
|
||||
class _Bubble extends StatelessWidget {
|
||||
const _Bubble({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: 100,
|
||||
height: 38,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
color: const Color(0xFF5388CF),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: campaignGradient,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: campaignText(
|
||||
12,
|
||||
500,
|
||||
AppColors.onCosmic,
|
||||
height: 1,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../../../core/ui/relative_time.dart';
|
||||
import '../campaign_data.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 목록의 캠페인 카드 — 명세 `523:18399`.
|
||||
///
|
||||
/// *작성일시 타임스탬프 기준 … · 캠페인 카드 업데이트 된 시점의 달성률 표시 ·*
|
||||
/// ***내용은 최대 2줄까지 이후는 … 처리*** *· 반응 수치는 K 단위 활용*
|
||||
///
|
||||
/// 시안(272:241184) — 이미지 342×228 위, 본문 #181818(#1e1e1e 획) 안쪽 14 · 간격 21,
|
||||
/// 작성자 줄 오른쪽 「NN% 달성」 그라디언트 배지, 1px #4f4f4f 구분선, 반응 넷, 흰색 r12
|
||||
/// 「캠페인 보기」.
|
||||
///
|
||||
/// [mine] 은 「내 캠페인」(`370:229665`) 변형 — 작성 시각과 반응 수치 없이 아이콘만 둔다.
|
||||
class CampaignListCard extends StatelessWidget {
|
||||
const CampaignListCard({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.onOpen,
|
||||
this.mine = false,
|
||||
});
|
||||
|
||||
final CampaignCardData card;
|
||||
final VoidCallback onOpen;
|
||||
final bool mine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 캠페인 문서에 대표 이미지 필드가 아직 없어 시안 표본을 쓴다.
|
||||
Image.asset(
|
||||
'assets/figma/campaign-sample.png',
|
||||
height: 228,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF181818),
|
||||
border: Border.all(color: const Color(0xFF1E1E1E)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(AppRadius.md),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CampaignAuthor(
|
||||
name: card.authorName,
|
||||
createdAt: mine ? null : card.createdAt,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
// 사본 「0% 달성」은 같은 그라디언트를 10% 로 깔아 회색에 가깝다.
|
||||
gradient: card.achievedPercent == 0
|
||||
? campaignGradient.scale(0.1)
|
||||
: campaignGradient,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.campaignAchieved(
|
||||
'${card.achievedPercent}',
|
||||
),
|
||||
style: campaignText(
|
||||
12,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
card.title,
|
||||
style: campaignText(
|
||||
14,
|
||||
400,
|
||||
AppColors.onCosmic,
|
||||
height: 1.3,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
card.body,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
const Color(0xFFC2C2C2),
|
||||
height: 1.4,
|
||||
),
|
||||
// 명세 — 내용은 최대 2줄까지, 이후는 … 처리.
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 21),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Color(0xFF4F4F4F),
|
||||
),
|
||||
const SizedBox(height: 21),
|
||||
Row(
|
||||
children: [
|
||||
_Reaction(AppIcons.heart, mine ? null : card.likes),
|
||||
_Reaction(
|
||||
AppIcons.messageCircle,
|
||||
mine ? null : card.comments,
|
||||
),
|
||||
_Reaction(AppIcons.eye, mine ? null : card.views),
|
||||
_Reaction(AppIcons.share, mine ? null : card.shares),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 21),
|
||||
Material(
|
||||
color: AppColors.onCosmic,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.campaignView,
|
||||
style: campaignText(
|
||||
14,
|
||||
600,
|
||||
Colors.black,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 작성자 줄 — 단색 원 26 · 닉네임(ABeeZee 12) · 상대 시각(10 #767676). 상세도 같이 쓴다.
|
||||
class CampaignAuthor extends StatelessWidget {
|
||||
const CampaignAuthor({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String name;
|
||||
|
||||
/// null 이면 시각 줄 없이 닉네임만(「내 캠페인」).
|
||||
final DateTime? createdAt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: const BoxDecoration(
|
||||
color: campaignAvatarColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: campaignText(
|
||||
12,
|
||||
400,
|
||||
AppColors.onCosmic,
|
||||
height: 1.3,
|
||||
family: AppFonts.display,
|
||||
),
|
||||
),
|
||||
if (createdAt != null)
|
||||
Text(
|
||||
relativeTime(createdAt!),
|
||||
style: campaignText(
|
||||
10,
|
||||
500,
|
||||
AppColors.onCosmicMuted,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _Reaction extends StatelessWidget {
|
||||
const _Reaction(this.icon, this.value);
|
||||
|
||||
final ImageProvider icon;
|
||||
|
||||
/// null 이면 아이콘만.
|
||||
final int? value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ImageIcon(icon, size: 14, color: AppColors.onCosmicMuted),
|
||||
if (value != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formatReaction(value!),
|
||||
style: campaignText(12, 500, AppColors.onCosmicMuted, height: 1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 캠페인 진행 기간 시트 — 사본 `333:72497`(시작일만) · `333:72719`(기간 선택 후).
|
||||
///
|
||||
/// 값은 변형 PNG(scale 2) 실측이다(변형 spec.json 미추출): 시트 위 모서리 24 · #101010,
|
||||
/// 날짜 칸 49×49(행 간격 4) · 선택 원 #FF7DDD · 기간 띠 #19191C, 일요일 #F24949 · 이웃 달 #999999,
|
||||
/// 버튼 56 높이(비활성 #333333 · 글자 #777777 / 활성 강조색 · 검정 글자).
|
||||
///
|
||||
/// 규칙은 OS 선택기 때와 같다 — 오늘부터 1년(365일) 안에서만 고른다. 시작 · 끝 순서로 누르고,
|
||||
/// 끝을 시작보다 앞에 누르면 그 날이 새 시작이 된다.
|
||||
Future<DateTimeRange?> showCampaignPeriodSheet(
|
||||
BuildContext context, {
|
||||
DateTimeRange? initial,
|
||||
DateTime? today,
|
||||
}) => showModalBottomSheet<DateTimeRange>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.cosmicSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => CampaignPeriodSheet(
|
||||
initial: initial,
|
||||
today: DateUtils.dateOnly(today ?? DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
class CampaignPeriodSheet extends StatefulWidget {
|
||||
const CampaignPeriodSheet({super.key, this.initial, required this.today});
|
||||
|
||||
final DateTimeRange? initial;
|
||||
final DateTime today;
|
||||
|
||||
@override
|
||||
State<CampaignPeriodSheet> createState() => _CampaignPeriodSheetState();
|
||||
}
|
||||
|
||||
class _CampaignPeriodSheetState extends State<CampaignPeriodSheet> {
|
||||
static const _cell = 49.0;
|
||||
static const _rowGap = 4.0;
|
||||
static const _band = Color(0xFF19191C);
|
||||
static const _sunday = Color(0xFFF24949);
|
||||
static const _outside = Color(0xFF999999);
|
||||
static const _muted = Color(0xFF777777);
|
||||
|
||||
late DateTime? _start = widget.initial?.start;
|
||||
late DateTime? _end = widget.initial?.end;
|
||||
late DateTime _month = DateTime(
|
||||
(_start ?? widget.today).year,
|
||||
(_start ?? widget.today).month,
|
||||
);
|
||||
|
||||
DateTime get _last => widget.today.add(const Duration(days: 365));
|
||||
|
||||
bool _selectable(DateTime d) =>
|
||||
!d.isBefore(widget.today) && !d.isAfter(_last);
|
||||
|
||||
void _pick(DateTime d) => setState(() {
|
||||
if (_start == null || _end != null || d.isBefore(_start!)) {
|
||||
_start = d;
|
||||
_end = null;
|
||||
} else {
|
||||
_end = d;
|
||||
}
|
||||
});
|
||||
|
||||
bool _canMove(int delta) {
|
||||
final m = DateTime(_month.year, _month.month + delta);
|
||||
final first = DateTime(widget.today.year, widget.today.month);
|
||||
final last = DateTime(_last.year, _last.month);
|
||||
return !m.isBefore(first) && !m.isAfter(last);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||||
final done = _start != null && _end != null;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottom + 13),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 4, 0),
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
icon: const ImageIcon(AppIcons.x, size: 24),
|
||||
color: AppColors.onCosmic,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignPeriodTitle,
|
||||
style: campaignText(
|
||||
20,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
context.l10n.campaignPeriodMax,
|
||||
style: campaignText(16, 400, _muted, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 31),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.campaignYearMonth(
|
||||
'${_month.year}',
|
||||
'${_month.month}',
|
||||
),
|
||||
style: campaignText(16, 400, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
const Spacer(),
|
||||
_MonthNav(
|
||||
onPrev: _canMove(-1)
|
||||
? () => setState(
|
||||
() =>
|
||||
_month = DateTime(_month.year, _month.month - 1),
|
||||
)
|
||||
: null,
|
||||
onNext: _canMove(1)
|
||||
? () => setState(
|
||||
() =>
|
||||
_month = DateTime(_month.year, _month.month + 1),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final w in context.l10n.campaignWeekdays.split(','))
|
||||
Expanded(
|
||||
child: Text(
|
||||
w,
|
||||
textAlign: TextAlign.center,
|
||||
style: campaignText(12, 400, const Color(0xFF767676)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _grid(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: FilledButton(
|
||||
onPressed: done
|
||||
? () => Navigator.of(
|
||||
context,
|
||||
).pop(DateTimeRange(start: _start!, end: _end!))
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: Colors.black,
|
||||
disabledBackgroundColor: const Color(0xFF333333),
|
||||
disabledForegroundColor: _muted,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: campaignText(16, 600, Colors.black),
|
||||
),
|
||||
child: Text(
|
||||
done
|
||||
? context.l10n.campaignPeriodRange(
|
||||
'${_start!.month}',
|
||||
'${_start!.day}',
|
||||
'${_end!.month}',
|
||||
'${_end!.day}',
|
||||
)
|
||||
: context.l10n.campaignPeriodSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 일요일 시작 주 단위 격자. 앞뒤 이웃 달 날짜로 칸을 채운다.
|
||||
Widget _grid() {
|
||||
final first = DateTime(_month.year, _month.month);
|
||||
final lead = first.weekday % 7;
|
||||
final days = DateUtils.getDaysInMonth(_month.year, _month.month);
|
||||
final weeks = ((lead + days) / 7).ceil();
|
||||
return Column(
|
||||
children: [
|
||||
for (var w = 0; w < weeks; w++) ...[
|
||||
if (w > 0) const SizedBox(height: _rowGap),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = 0; i < 7; i++)
|
||||
_day(
|
||||
DateTime(first.year, first.month, 1 - lead + w * 7 + i),
|
||||
i,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _day(DateTime d, int column) {
|
||||
final inMonth = d.month == _month.month;
|
||||
final isStart = _start != null && DateUtils.isSameDay(d, _start);
|
||||
final isEnd = _end != null && DateUtils.isSameDay(d, _end);
|
||||
final inRange =
|
||||
_start != null &&
|
||||
_end != null &&
|
||||
!d.isBefore(_start!) &&
|
||||
!d.isAfter(_end!) &&
|
||||
!DateUtils.isSameDay(_start, _end);
|
||||
// 기간 띠는 시작 · 끝 원의 가운데에서 끊는다.
|
||||
final band = inRange
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(color: isStart ? Colors.transparent : _band),
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(color: isEnd ? Colors.transparent : _band),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
final selected = isStart || isEnd;
|
||||
final color = selected
|
||||
? AppColors.onCosmic
|
||||
: !inMonth
|
||||
? _outside
|
||||
: column == 0
|
||||
? _sunday
|
||||
: AppColors.onCosmic;
|
||||
final enabled = inMonth && _selectable(d);
|
||||
return Expanded(
|
||||
child: SizedBox(
|
||||
height: _cell,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (band != null) Positioned.fill(child: band),
|
||||
if (selected)
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: SizedBox.square(dimension: _cell),
|
||||
),
|
||||
Semantics(
|
||||
button: enabled,
|
||||
selected: selected,
|
||||
child: InkResponse(
|
||||
onTap: enabled ? () => _pick(d) : null,
|
||||
radius: _cell / 2,
|
||||
child: SizedBox.square(
|
||||
dimension: _cell,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${d.day}',
|
||||
style: campaignText(12, 400, color, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 월 이동 — 사본 64×28 알약(획 #474747) 안의 ‹ | ›.
|
||||
class _MonthNav extends StatelessWidget {
|
||||
const _MonthNav({this.onPrev, this.onNext});
|
||||
|
||||
final VoidCallback? onPrev;
|
||||
final VoidCallback? onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget arrow(ImageProvider icon, String label, VoidCallback? onTap) =>
|
||||
Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 16,
|
||||
child: SizedBox(
|
||||
width: 31.5,
|
||||
height: 28,
|
||||
child: Center(
|
||||
child: ImageIcon(icon, size: 20, color: AppColors.onCosmic),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF474747)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
arrow(AppIcons.chevronLeft, context.l10n.calendarPrevMonth, onPrev),
|
||||
Container(width: 1, height: 10, color: const Color(0xFF474747)),
|
||||
arrow(AppIcons.chevronRight, context.l10n.calendarNextMonth, onNext),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/relative_time.dart';
|
||||
import '../campaign_data.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 기여 랭킹 조각 — 시안 `333:70971`(사용자 랭킹) · 캠페인 상세 「실시간 랭킹」 공용.
|
||||
|
||||
/// 랭킹 데이터에 사진 필드가 없어 시안 목록의 단색 원을 순위 순서대로 돌려 쓴다.
|
||||
const _avatarColors = [
|
||||
Color(0xFF000000),
|
||||
Color(0xFF7358E9),
|
||||
Color(0xFFFA46E8),
|
||||
Color(0xFFC6D284),
|
||||
Color(0xFFF4CFFA),
|
||||
];
|
||||
|
||||
/// 순위 아바타. 내 줄은 MY 프로필과 같은 시안 표본을 쓴다.
|
||||
class RankAvatar extends StatelessWidget {
|
||||
const RankAvatar({super.key, required this.rank, required this.size});
|
||||
|
||||
final ContributorRank rank;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => rank.isMe
|
||||
? ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/figma/avatar-sample.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// 시안 목록은 4위부터 검정 · 보라 · 분홍 · 올리브 · 연분홍 순이다.
|
||||
color: _avatarColors[(rank.rank + 1) % _avatarColors.length],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 순위 숫자 아래 변동값 — 상승 #f28180 ▲ · 하락 #6866f3 ▼ · 변동 없음 「-」.
|
||||
class RankChange extends StatelessWidget {
|
||||
const RankChange({super.key, required this.change});
|
||||
|
||||
final int change;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (change == 0) {
|
||||
return Text(
|
||||
'-',
|
||||
style: campaignText(10, 500, const Color(0xFF777777), height: 1),
|
||||
);
|
||||
}
|
||||
final up = change > 0;
|
||||
final color = up ? const Color(0xFFF28180) : AppColors.neonViolet;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
up ? '▲' : '▼',
|
||||
style: TextStyle(fontSize: 8, height: 1, color: color),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text('${change.abs()}', style: campaignText(10, 500, color, height: 1)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 순위 한 줄 — 높이 60 · [순위+변동] 28 [아바타 40(원 36) · 6 · 닉네임 14/500 #979b9e] … 기여 POP 16/600.
|
||||
class RankRow extends StatelessWidget {
|
||||
const RankRow({
|
||||
super.key,
|
||||
required this.rank,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 10),
|
||||
});
|
||||
|
||||
final ContributorRank rank;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 17),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${rank.rank}',
|
||||
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
RankChange(change: rank.change),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 28),
|
||||
// 사본 — 40 프레임 가운데 지름 36 원.
|
||||
SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Center(child: RankAvatar(rank: rank, size: 36)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
rank.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: campaignText(14, 500, const Color(0xFF979B9E), height: 1),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatReaction(rank.pop),
|
||||
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 1~3위 단상 — 2 · 1 · 3 순서, 1위만 20 위로 올린다. 왕관 24 · 아바타 80.
|
||||
class RankPodium extends StatelessWidget {
|
||||
const RankPodium({super.key, required this.top});
|
||||
|
||||
/// 순위 오름차순 앞 세 줄.
|
||||
final List<ContributorRank> top;
|
||||
|
||||
static const _crown = {
|
||||
1: Color(0xFFFEA300),
|
||||
2: Color(0xFFACACAC),
|
||||
3: Color(0xFF9C724A),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final byRank = {for (final r in top) r.rank: r};
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (i, place) in const [2, 1, 3].indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 24),
|
||||
SizedBox(width: 81, child: _place(byRank[place], place)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _place(ContributorRank? r, int place) => r == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(top: place == 1 ? 0 : 20),
|
||||
child: Column(
|
||||
children: [
|
||||
_Crown(color: _crown[place]!, place: place),
|
||||
RankAvatar(rank: r, size: 80),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
r.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: campaignText(14, 600, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatReaction(r.pop),
|
||||
style: campaignText(16, 600, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 체스 킹 왕관(시안 material-symbols:chess-king) — 십자 · 몸통을 그리고 가운데에 순위.
|
||||
class _Crown extends StatelessWidget {
|
||||
const _Crown({required this.color, required this.place});
|
||||
|
||||
final Color color;
|
||||
final int place;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CustomPaint(
|
||||
painter: _CrownPainter(color),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$place',
|
||||
style: campaignText(10, 900, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CrownPainter extends CustomPainter {
|
||||
const _CrownPainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final p = Paint()..color = color;
|
||||
final w = size.width;
|
||||
// 십자
|
||||
canvas.drawRect(Rect.fromLTWH(w / 2 - 1, 1, 2, 7), p);
|
||||
canvas.drawRect(Rect.fromLTWH(w / 2 - 3.5, 3, 7, 2), p);
|
||||
// 몸통
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(3, 8, w - 6, 15),
|
||||
const Radius.circular(4),
|
||||
),
|
||||
p,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_CrownPainter old) => old.color != color;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../campaign_data.dart';
|
||||
import 'campaign_gauge.dart';
|
||||
import 'campaign_style.dart';
|
||||
|
||||
/// 공유 대상. 앞의 셋은 OS 공유 시트로, 링크는 문구 복사로 이어진다.
|
||||
enum CampaignShareTarget {
|
||||
instagram('assets/figma/campaign-share-instagram.png'),
|
||||
tiktok('assets/figma/campaign-share-tiktok.png'),
|
||||
x('assets/figma/campaign-share-x.png'),
|
||||
link('assets/figma/campaign-share-link.png');
|
||||
|
||||
const CampaignShareTarget(this.asset);
|
||||
final String asset;
|
||||
|
||||
String get label => switch (this) {
|
||||
instagram => appL10n.shareInstagram,
|
||||
tiktok => appL10n.shareTiktok,
|
||||
x => 'X',
|
||||
link => appL10n.shareCopyLink,
|
||||
};
|
||||
}
|
||||
|
||||
/// 캠페인 공유 오버레이 — 시안 `480:14594`.
|
||||
///
|
||||
/// #101010 r24 카드(좌우 20 여백) 안에 닫기 · 「캠페인 공유」 25/600 · 대표 이미지 303 정사각 ·
|
||||
/// 제목 24/500 · 본문 · 기여률 카드 · 공유 원형 버튼 넷(36 · 간격 16).
|
||||
class CampaignShareDialog extends StatelessWidget {
|
||||
const CampaignShareDialog({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.raised,
|
||||
});
|
||||
|
||||
final CampaignCardData card;
|
||||
final int raised;
|
||||
|
||||
/// 시안은 오버레이 카드가 상태 표시줄 자리까지 올라온다(위 23 · 아래 22). [Dialog] 는
|
||||
/// 안전 영역을 비켜 가므로 [showGeneralDialog] 로 직접 띄운다.
|
||||
static Future<CampaignShareTarget?> show(
|
||||
BuildContext context, {
|
||||
required CampaignCardData card,
|
||||
required int raised,
|
||||
}) => showGeneralDialog<CampaignShareTarget>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: context.l10n.commonClose,
|
||||
barrierColor: AppColors.cosmicVoid,
|
||||
pageBuilder: (_, _, _) => CampaignShareDialog(card: card, raised: raised),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 23, 20, 22),
|
||||
child: Material(
|
||||
color: const Color(0xFF101010),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkResponse(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
radius: 20,
|
||||
child: Tooltip(
|
||||
message: context.l10n.commonClose,
|
||||
child: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.campaignShareTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: campaignText(
|
||||
25,
|
||||
600,
|
||||
AppColors.onCosmic,
|
||||
height: 1.4,
|
||||
spacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Image.asset(
|
||||
'assets/figma/campaign-sample.png',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
card.title,
|
||||
style: campaignText(24, 500, AppColors.onCosmic, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
card.body,
|
||||
style: campaignText(
|
||||
14,
|
||||
500,
|
||||
const Color(0xFFC2C2C2),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CampaignGauge(raised: raised, goal: card.goalPop),
|
||||
const SizedBox(height: 36),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final (i, target)
|
||||
in CampaignShareTarget.values.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 16),
|
||||
Tooltip(
|
||||
message: target.label,
|
||||
child: InkResponse(
|
||||
onTap: () => Navigator.of(context).pop(target),
|
||||
radius: 22,
|
||||
child: _icon(target),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _icon(CampaignShareTarget target) => switch (target) {
|
||||
CampaignShareTarget.x => Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(target.asset, width: 21, height: 21),
|
||||
),
|
||||
_ => ClipOval(
|
||||
child: Image.asset(
|
||||
target.asset,
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
|
||||
/// Figma 사본(F5) 글자 — 크기 · 굵기 · 색 · 줄 높이를 spec.json 값 그대로 옮긴다.
|
||||
///
|
||||
/// 자간은 Figma 의 -2.5% 를 기본으로 둔다(글자 크기 비례). ABeeZee 에 한글이 없어
|
||||
/// 한글은 42dot Sans 로 떨어진다 — Figma 렌더와 같은 결과다.
|
||||
TextStyle campaignText(
|
||||
double size,
|
||||
int weight,
|
||||
Color color, {
|
||||
double? height,
|
||||
double spacing = -0.025,
|
||||
String family = AppFonts.body,
|
||||
}) => TextStyle(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: const [AppFonts.body],
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.values[weight ~/ 100 - 1],
|
||||
height: height,
|
||||
letterSpacing: size * spacing,
|
||||
color: color,
|
||||
);
|
||||
|
||||
/// 달성 배지 · 기여 게이지 · 말풍선의 청록→보라 그라디언트(Figma 이미지 채움 실측).
|
||||
const campaignGradient = LinearGradient(
|
||||
colors: [AppColors.cosmicTeal, Color(0xFF5A69DE)],
|
||||
);
|
||||
|
||||
/// 작성자 아바타 자리. 캠페인 문서에 작성자 사진 필드가 없어 시안의 단색 원을 쓴다.
|
||||
const campaignAvatarColor = Color(0xFFED7E7E);
|
||||
@@ -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