From 18afc9cb98ae35baaa10e6ff0109383113ddf7ec Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 29 Sep 2026 17:06:48 +0900 Subject: [PATCH] =?UTF-8?q?QUEST=20=C2=B7=20CAMPAIGN=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=E2=80=94=20=ED=80=98=EC=8A=A4=ED=8A=B8=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=C2=B7=EC=83=9D=EC=84=B1=C2=B7=EC=9D=B8=EC=A6=9D=20=C2=B7=20?= =?UTF-8?q?=EC=BA=A0=ED=8E=98=EC=9D=B8=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81?= =?UTF-8?q?=EC=84=B8=C2=B7=ED=9B=84=EC=9B=90=C2=B7=EB=9E=AD=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../campaign/campaign_create_screen.dart | 529 +++++++ app/lib/features/campaign/campaign_data.dart | 622 ++++++++ .../campaign/campaign_detail_screen.dart | 1301 +++++++++++++++++ .../campaign/campaign_list_screen.dart | 302 ++++ .../campaign/campaign_ranking_screen.dart | 165 +++ .../campaign_repository_firestore.dart | 483 ++++++ .../campaign/campaign_search_screen.dart | 321 ++++ app/lib/features/campaign/campaign_share.dart | 83 ++ .../campaign/widgets/campaign_gauge.dart | 161 ++ .../campaign/widgets/campaign_list_card.dart | 246 ++++ .../widgets/campaign_period_sheet.dart | 352 +++++ .../campaign/widgets/campaign_rank.dart | 233 +++ .../widgets/campaign_share_dialog.dart | 164 +++ .../campaign/widgets/campaign_style.dart | 32 + .../features/quest/place_picker_screen.dart | 139 ++ app/lib/features/quest/quest_ad_screens.dart | 361 +++++ .../features/quest/quest_create_screen.dart | 576 ++++++++ app/lib/features/quest/quest_data.dart | 771 ++++++++++ .../features/quest/quest_detail_screen.dart | 868 +++++++++++ app/lib/features/quest/quest_evidence.dart | 148 ++ app/lib/features/quest/quest_list_screen.dart | 388 +++++ .../quest/quest_repository_firestore.dart | 564 +++++++ .../features/quest/quest_reward_screen.dart | 122 ++ .../quest/widgets/quest_card_tile.dart | 380 +++++ .../quest/widgets/time_attack_countdown.dart | 57 + 25 files changed, 9368 insertions(+) create mode 100644 app/lib/features/campaign/campaign_create_screen.dart create mode 100644 app/lib/features/campaign/campaign_data.dart create mode 100644 app/lib/features/campaign/campaign_detail_screen.dart create mode 100644 app/lib/features/campaign/campaign_list_screen.dart create mode 100644 app/lib/features/campaign/campaign_ranking_screen.dart create mode 100644 app/lib/features/campaign/campaign_repository_firestore.dart create mode 100644 app/lib/features/campaign/campaign_search_screen.dart create mode 100644 app/lib/features/campaign/campaign_share.dart create mode 100644 app/lib/features/campaign/widgets/campaign_gauge.dart create mode 100644 app/lib/features/campaign/widgets/campaign_list_card.dart create mode 100644 app/lib/features/campaign/widgets/campaign_period_sheet.dart create mode 100644 app/lib/features/campaign/widgets/campaign_rank.dart create mode 100644 app/lib/features/campaign/widgets/campaign_share_dialog.dart create mode 100644 app/lib/features/campaign/widgets/campaign_style.dart create mode 100644 app/lib/features/quest/place_picker_screen.dart create mode 100644 app/lib/features/quest/quest_ad_screens.dart create mode 100644 app/lib/features/quest/quest_create_screen.dart create mode 100644 app/lib/features/quest/quest_data.dart create mode 100644 app/lib/features/quest/quest_detail_screen.dart create mode 100644 app/lib/features/quest/quest_evidence.dart create mode 100644 app/lib/features/quest/quest_list_screen.dart create mode 100644 app/lib/features/quest/quest_repository_firestore.dart create mode 100644 app/lib/features/quest/quest_reward_screen.dart create mode 100644 app/lib/features/quest/widgets/quest_card_tile.dart create mode 100644 app/lib/features/quest/widgets/time_attack_countdown.dart diff --git a/app/lib/features/campaign/campaign_create_screen.dart b/app/lib/features/campaign/campaign_create_screen.dart new file mode 100644 index 0000000..2412746 --- /dev/null +++ b/app/lib/features/campaign/campaign_create_screen.dart @@ -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 createState() => _CampaignCreateScreenState(); +} + +class _CampaignCreateScreenState extends State { + 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 _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 _confirm() async { + final go = await showDialog( + 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( + 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); + } +} diff --git a/app/lib/features/campaign/campaign_data.dart b/app/lib/features/campaign/campaign_data.dart new file mode 100644 index 0000000..f90f6a1 --- /dev/null +++ b/app/lib/features/campaign/campaign_data.dart @@ -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 [ + (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 mediaLabels; + + final DateTime endsAt; + final List 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 photoUrls; + final List 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> load({ + CampaignSort sort = CampaignSort.latest, + int limit = 10, + }); + + Future> more({required int offset, int limit = 10}); + + Future detail(String campaignId); + + Future> ranking( + String campaignId, { + RankWindow window = RankWindow.live, + }); + + Future> recentQueries(); + + Future clearQueries(); + + /// 최근 검색어 한 개 지우기 — 시안 `272:241681` 검색어 칩의 ✕. + Future removeQuery(String query); + + Future> search(String query); + + /// 기여. **잔액은 서버가 깎는다** — `fn-campaign-fund` 가 원장을 지난다. + /// 실패하면 던진다 — 화면은 성공으로 그리지 않는다. + Future contribute(String campaignId, int amountPop); + + /// 후원에 쓸 수 있는 잔액 — 기여 POP(정책 원장 B-33). + Future balance(); + + /// 등록비에 쓸 수 있는 잔액 — 일반 + 기여 POP. 등록비는 겸용이다(B-57 · B-06). + Future feeBalance(); + + /// 좋아요 저장. 실패하면 던진다. + Future like(String campaignId, bool on); + + Future create(CampaignDraft draft); + + /// 내가 만든 캠페인 — 공개 전 · 철회 · 취소 상태까지(MY/내 캠페인). + Future> mine(); + + /// 개설자 철회(`fn-campaign-withdraw` · B-58). 실패하면 던진다. + Future withdraw(String campaignId); + + /// 응원 게시판 댓글 한 장 — 최신순(명세 `527:21192`). [more] 가 false 면 첫 + /// 장부터 다시 읽는다. 차단한 작성자의 댓글은 뺀다. + Future> comments( + String campaignId, { + bool more = false, + }); + + /// 댓글 저장. 실패하면 던진다. + Future addComment(String campaignId, String body); + + Future deleteComment(String campaignId, String commentId); + + /// 신고 — [reason] 은 신고 화면의 사유 문구(`reportReasonCodes` 의 키). + Future reportComment( + String campaignId, + CampaignComment comment, + String reason, + ); + + /// 작성자 차단 — 이후 그 작성자의 댓글은 목록에서 빠진다. + Future blockAuthor(CampaignComment comment); + + /// 댓글 좋아요 저장 — 1인 1회. 실패하면 던진다. + Future likeComment(String campaignId, String commentId, bool on); + + /// 캠페인 신고 — [reason] 은 신고 화면의 사유 문구. 같은 사람의 재신고는 던진다. + Future reportCampaign(String campaignId, String reason); + + /// 개설자 차단 — 이후 그 개설자의 캠페인 · 댓글이 목록에서 빠진다. + Future blockCreator(CampaignCardData card); + + /// 댓글 작성 자격 — 공개 닉네임이 있는가(비었거나 이메일 형태면 없음). 규칙도 + /// 응원 게시판 댓글에는 「팬」 대체값을 받지 않는다. + Future hasPublicNickname(); + + /// 닉네임을 정하러 갈 MY 저장소(닉네임 수정 화면이 쓴다). + MyRepository get my; +} + +/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** +class SampleCampaignRepository implements CampaignRepository { + SampleCampaignRepository(); + + static final _base = DateTime(2026, 9, 1, 12); + + final List _queries = ['강남역', 'LED', '커피차']; + + static final List _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> 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> more({ + required int offset, + int limit = 10, + }) async => _cards.skip(offset).take(limit).toList(); + + @override + Future 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> 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> recentQueries() async => [..._queries]; + + @override + Future clearQueries() async => _queries.clear(); + + @override + Future removeQuery(String query) async => _queries.remove(query); + + @override + Future> 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 contribute(String campaignId, int amountPop) async => amountPop; + + @override + Future balance() async => 12540; + + @override + Future feeBalance() => balance(); + + @override + Future like(String campaignId, bool on) async {} + + @override + Future create(CampaignDraft draft) async => 'c-new'; + + @override + Future> mine() async => + (await load(limit: 100)).where((c) => c.mine).toList(); + + @override + Future withdraw(String campaignId) async {} + + late final List _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> comments( + String campaignId, { + bool more = false, + }) async => more ? const [] : [..._comments]; + + @override + Future 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 deleteComment(String campaignId, String commentId) async => + _comments.removeWhere((c) => c.id == commentId); + + @override + Future reportComment( + String campaignId, + CampaignComment comment, + String reason, + ) async {} + + @override + Future blockAuthor(CampaignComment comment) async => + _comments.removeWhere((c) => c.authorUid == comment.authorUid); + + @override + Future likeComment( + String campaignId, + String commentId, + bool on, + ) async {} + + @override + Future reportCampaign(String campaignId, String reason) async {} + + @override + Future blockCreator(CampaignCardData card) async {} + + @override + Future hasPublicNickname() async => true; + + @override + late final MyRepository my = SampleMyRepository(); +} diff --git a/app/lib/features/campaign/campaign_detail_screen.dart b/app/lib/features/campaign/campaign_detail_screen.dart new file mode 100644 index 0000000..5089930 --- /dev/null +++ b/app/lib/features/campaign/campaign_detail_screen.dart @@ -0,0 +1,1301 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.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/relative_time.dart'; +import '../home/home_screen.dart' show formatPop; +import '../my/profile_edit_screen.dart'; +import '../planet/moderation.dart'; +import '../translation/translation_comments.dart'; +import '../translation/translation_data.dart'; +import 'campaign_data.dart'; +import 'campaign_ranking_screen.dart'; +import 'campaign_share.dart'; +import 'widgets/campaign_gauge.dart'; +import 'widgets/campaign_list_card.dart' show CampaignAuthor; +import 'widgets/campaign_rank.dart'; +import 'widgets/campaign_share_dialog.dart'; +import 'widgets/campaign_style.dart'; + +/// 캠페인 상세 — 화면 대조 원장 F5 · Figma `272:245738`. +/// +/// 신고하기는 `272:246311` 이며 [ReportScreen] 을 공용으로 쓴다. +/// +/// 명세 `523:18493` — *디폴트 첫 번째 사진 노출 · 사진/영상 개수에 따른 인디케이터 수* +/// 명세 `523:18525` — *상세 페이지 진입과 동시에 조회수 +1 · 좋아요 클릭 시 +1* +/// 명세 `523:18559` — *캠페인 정보 탭 · 응원 게시판 +댓글수 탭 · 디폴트 캠페인 정보* +/// 명세 `523:18569` — *기여율 · 누적 POP · 목표 · 참여 인원 · 남은 시간* +/// 명세 `523:18589` — *기여하기 버튼은 **타인 캠페인에서만** 표시* +class CampaignDetailScreen extends StatefulWidget { + CampaignDetailScreen({ + super.key, + required this.campaignId, + CampaignRepository? repository, + this.myPop, + this.translation, + }) : repository = repository ?? SampleCampaignRepository(); + + final String campaignId; + final CampaignRepository repository; + + /// 후원 가능 잔액. 넘기지 않으면 저장소에서 읽는다(기여 POP · B-33). + final int? myPop; + + /// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다. + final TranslationRepository? translation; + + @override + State createState() => _CampaignDetailScreenState(); +} + +class _CampaignDetailScreenState extends State { + final _media = PageController(); + + CampaignDetail? _detail; + int _page = 0; + int _tabIndex = 0; + int _likes = 0; + bool _liked = false; + int _raised = 0; + int _spent = 0; + int _balance = 0; + Object? _error; + + /// 응원 게시판 댓글(최신순). null 이면 아직 읽는 중. + List? _comments; + bool _moreComments = false; + int _commentCount = 0; + + /// 공개 닉네임이 있는가 — 없으면 입력 대신 닉네임 안내를 띄운다. + bool _hasNickname = true; + + /// 번역할 댓글. 없으면 번역 댓글은 캠페인 본문의 번역이다(Q-TR-07 A안). + TranslationReplyTarget? _replyTo; + + TranslationRepository? get _translation => + translationSurfaces.contains(TranslationSurface.campaignBoard) + ? widget.translation ?? TranslationScope.maybeOf(context) + : null; + + String get _root => 'campaigns/${widget.campaignId}'; + + @override + void initState() { + super.initState(); + _load(); + _loadComments(); + } + + @override + void dispose() { + _media.dispose(); + super.dispose(); + } + + Future _load() async { + final CampaignDetail detail; + final int balance; + try { + detail = await widget.repository.detail(widget.campaignId); + balance = widget.myPop ?? await widget.repository.balance(); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + return; + } + if (!mounted) return; + setState(() { + _balance = balance; + _detail = detail; + _likes = detail.card.likes; + _liked = detail.liked; + _raised = detail.card.raisedPop; + _commentCount = detail.card.comments; + }); + } + + Future _loadComments({bool more = false}) async { + final List page; + try { + if (!more) _hasNickname = await widget.repository.hasPublicNickname(); + page = await widget.repository.comments(widget.campaignId, more: more); + } on Object catch (error) { + if (!mounted) return; + setState(() => _comments ??= const []); + _fail(error); + return; + } + if (!mounted) return; + setState(() { + _comments = [if (more) ...?_comments, ...page]; + _moreComments = page.length >= campaignCommentPageSize; + }); + } + + Future _send(String body) async { + try { + final c = await widget.repository.addComment(widget.campaignId, body); + if (!mounted) return true; + setState(() { + _comments = [c, ...?_comments]; + _commentCount++; + }); + return true; + } on Object catch (error) { + _fail(error); + return false; + } + } + + /// 댓글 좋아요 — 저장이 끝난 뒤에만 숫자를 바꾼다(캠페인 좋아요와 같은 방식). + Future _likeComment(CampaignComment c) async { + final on = !c.liked; + try { + await widget.repository.likeComment(widget.campaignId, c.id, on); + } on Object catch (error) { + _fail(error); + return; + } + if (!mounted) return; + final next = CampaignComment( + id: c.id, + authorName: c.authorName, + body: c.body, + createdAt: c.createdAt, + authorUid: c.authorUid, + isMine: c.isMine, + liked: on, + likes: c.likes + (on ? 1 : -1), + ); + setState( + () => _comments = [ + for (final x in _comments ?? const []) + x == c ? next : x, + ], + ); + } + + /// 댓글 카드의 더보기 — 내 댓글은 삭제, 남의 댓글은 신고 · 차단(명세 `527:21192`). + Future _commentAction(CampaignComment c, String action) async { + final repo = widget.repository; + try { + switch (action) { + case 'delete': + if (!await confirmOverlay( + context, + context.l10n.commentDeleteConfirm, + )) { + return; + } + await repo.deleteComment(widget.campaignId, c.id); + if (!mounted) return; + setState(() { + _comments = [...?_comments]..remove(c); + if (_commentCount > 0) _commentCount--; + }); + case 'report': + final reason = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget( + authorName: c.authorName, + title: c.body, + ), + ), + ), + ); + if (reason == null) return; + await repo.reportComment(widget.campaignId, c, reason); + if (mounted) _toast(context.l10n.reportReceived); + case 'block': + if (!await confirmBlock(context, c.authorName)) return; + await repo.blockAuthor(c); + if (!mounted) return; + // 차단한 작성자의 댓글은 바로 숨긴다 — 다음 읽기부터는 저장소가 뺀다. + setState( + () => _comments = [ + for (final x in _comments ?? const []) + if (x.authorUid != c.authorUid) x, + ], + ); + _toast(context.l10n.blocked); + } + } on Object catch (error) { + _fail(error); + } + } + + @override + Widget build(BuildContext context) { + final detail = _detail; + if (detail == null) { + final error = _error; + return Scaffold( + appBar: error == null ? null : AppBar(), + body: Center( + child: error == null + ? const CircularProgressIndicator() + : Text( + error is BackendException + ? error.message + : context.l10n.campaignLoadFailed, + ), + ), + ); + } + final card = detail.card; + + // 시안(272:245738) — 대표 이미지 360 위에 반투명 원형 버튼 둘(42 · #000 60%)이 뜨고, + // 제목 · 본문 · 탭 · 펀딩 현황이 한 스크롤에 이어진다. 탭은 고정 헤더가 아니다. + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + toolbarHeight: 54, + leadingWidth: 58, + leading: const Padding( + padding: EdgeInsets.only(left: 16), + child: _Round(child: BackButton()), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: _Round( + child: PopupMenuButton( + tooltip: context.l10n.commonMore, + icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24), + onSelected: (v) => _moderate(v, card), + itemBuilder: (_) => [ + PopupMenuItem( + value: 'report', + child: Text(context.l10n.reportTitle), + ), + PopupMenuItem( + value: 'block', + child: Text(context.l10n.commonBlockAction), + ), + ], + ), + ), + ), + ], + ), + body: ListView( + key: const ValueKey('campaign-info'), + padding: EdgeInsets.only(bottom: card.mine ? AppSpace.s5 : 96), + children: [ + _hero(detail), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: CampaignAuthor( + name: card.authorName, + createdAt: card.createdAt, + ), + ), + Tooltip( + message: context.l10n.commonShare, + child: Material( + color: AppColors.cosmicSurfaceContainer, + shape: const StadiumBorder(), + child: InkWell( + onTap: () => _share(card), + customBorder: const StadiumBorder(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.commonShare, + style: campaignText( + 12, + 600, + AppColors.onCosmicVariant, + height: 1, + spacing: 0, + ), + ), + const SizedBox(width: 4), + const ImageIcon( + AppIcons.share, + size: 14, + color: AppColors.onCosmicVariant, + ), + ], + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + 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: 12), + Row( + children: [ + InkResponse( + onTap: _like, + radius: 16, + child: Tooltip( + message: context.l10n.like, + child: Icon( + _liked ? Icons.favorite : Icons.favorite_border, + size: 16, + color: _liked + ? AppColors.cosmicAccent + : AppColors.onCosmic, + ), + ), + ), + const SizedBox(width: 4), + Text(formatReaction(_likes), style: _reaction), + const SizedBox(width: 12), + const Icon( + Icons.visibility_outlined, + size: 16, + color: AppColors.onCosmic, + ), + const SizedBox(width: 4), + // 진입과 동시에 +1. + Text(formatReaction(card.views + 1), style: _reaction), + const SizedBox(width: 12), + const Icon( + Icons.share_outlined, + size: 16, + color: AppColors.onCosmic, + ), + const SizedBox(width: 4), + Text(formatReaction(card.shares), style: _reaction), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + _tab(context.l10n.campaignTabInfo, 0), + _tab(context.l10n.campaignTabBoard('$_commentCount'), 1), + ], + ), + ), + if (_tabIndex == 0) _info(detail) else _board(), + ], + ), + // 명세 — 기여하기는 타인 캠페인에서만. 내 캠페인은 철회할 수 있을 때 철회 버튼(B-58). + // 응원 게시판 탭은 하단에 댓글 입력 영역을 고정한다(명세 `527:21196`). + // 기본은 일반 댓글, 지구본을 켜면 캠페인 본문의 번역 댓글이다. + bottomNavigationBar: _tabIndex == 1 && !_hasNickname + ? _nicknamePrompt() + : _tabIndex == 1 + ? CommentInputBar( + hint: context.l10n.commentHint, + onComment: _send, + // 규칙의 댓글 상한과 같은 수. + maxLength: 1000, + translation: _translation, + rootPath: _root, + replyTo: _replyTo, + onClearReply: () => setState(() => _replyTo = null), + ) + : card.mine + ? (_withdrawable(card) + ? SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: OutlinedButton( + onPressed: () => _withdraw(card), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + foregroundColor: AppColors.onCosmic, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: Text(context.l10n.campaignWithdraw), + ), + ), + ) + : null) + : SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: FilledButton( + onPressed: () => _contribute(detail), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: AppColors.onCosmic, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + textStyle: campaignText(14, 600, Colors.black, height: 1.3), + ), + child: Text(context.l10n.campaignContributeAction), + ), + ), + ), + ); + } + + /// 닉네임 없는 사용자 — 응원 게시판 댓글은 「팬」으로 쓸 수 없다(규칙과 같은 조건). + Widget _nicknamePrompt() => ColoredBox( + color: AppColors.cosmicVoid, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 10, 10), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.campaignCommentNeedNickname, + style: campaignText(14, 500, AppColors.onCosmicMuted), + ), + ), + TextButton( + onPressed: () async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => ProfileEditScreen( + nickname: '', + repository: widget.repository.my, + ), + ), + ); + final ok = await widget.repository.hasPublicNickname(); + if (mounted) setState(() => _hasNickname = ok); + }, + child: Text(context.l10n.campaignCommentSetNickname), + ), + ], + ), + ), + ), + ); + + static final _reaction = campaignText(12, 500, AppColors.onCosmic, height: 1); + + /// 대표 이미지 — 상태 표시줄을 포함해 360. 인디케이터 수는 사진 개수를 따른다. + Widget _hero(CampaignDetail detail) => SizedBox( + height: 312 + MediaQuery.paddingOf(context).top, + child: Stack( + fit: StackFit.expand, + children: [ + PageView( + // 사진 슬라이드를 가려내는 표식. + key: const ValueKey('campaign-media'), + controller: _media, + onPageChanged: (i) => setState(() => _page = i), + children: [ + // 캠페인 문서의 사진은 아직 라벨뿐이라 시안 표본을 깐다. + for (final label in detail.mediaLabels) + Image.asset( + 'assets/figma/campaign-sample.png', + fit: BoxFit.cover, + semanticLabel: label, + ), + ], + ), + if (detail.mediaLabels.length > 1) + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < detail.mediaLabels.length; i++) + Container( + width: i == _page ? 16 : 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.full), + color: i == _page + ? AppColors.onCosmic + : AppColors.onCosmic.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ], + ), + ); + + /// 탭 — 반폭 · 글자 14/500 · 선택 흰 글자·흰 밑줄, 비선택 #84868e · #1b1c1c 밑줄. + Widget _tab(String label, int index) { + final on = _tabIndex == index; + return Expanded( + child: InkWell( + onTap: () => setState(() => _tabIndex = index), + child: Container( + height: 37, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: on ? AppColors.onCosmic : const Color(0xFF1B1C1C), + ), + ), + ), + child: Text( + label, + style: campaignText( + 14, + 500, + on ? AppColors.onCosmic : const Color(0xFF84868E), + spacing: 0, + ), + ), + ), + ), + ); + } + + Widget _info(CampaignDetail detail) { + final card = detail.card; + final sorted = [...detail.topContributors] + ..sort((a, b) => a.rank.compareTo(b.rank)); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (card.status.notice case final notice?) ...[ + Container( + key: const ValueKey('campaign-status-notice'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + notice, + style: campaignText(14, 500, AppColors.onCosmic, height: 1.4), + ), + ), + const SizedBox(height: 12), + ], + Text( + context.l10n.campaignFundingStatus, + style: campaignText(18, 600, AppColors.onCosmic, spacing: 0), + ), + const SizedBox(height: 12), + CampaignGauge(raised: _raised, goal: card.goalPop), + const SizedBox(height: 6), + Row( + children: [ + _stat( + formatPop(card.supporters + (_spent > 0 ? 1 : 0)), + context.l10n.campaignSupporting, + ), + const SizedBox(width: 6), + _stat(_left(detail.remaining()), context.l10n.campaignTimeLeft), + ], + ), + const SizedBox(height: AppSpace.s5), + CampaignRankHeader( + window: RankWindow.live, + onTap: () => _openRanking(detail), + ), + const SizedBox(height: 12), + RankPodium(top: sorted.take(3).toList()), + for (final r in sorted.skip(3)) RankRow(rank: r), + if (detail.report case final report?) ...[ + const SizedBox(height: AppSpace.s5), + _reportSection(report), + ], + ], + ), + ); + } + + /// 결과보고 — 집행 사진 · 영수증 · 예산 사용 내역(FR-CMP-06). 운영이 올린다(B-35). + Widget _reportSection(CampaignReport report) { + final heading = campaignText(18, 600, AppColors.onCosmic, spacing: 0); + final sub = campaignText(14, 600, AppColors.onCosmic, spacing: 0); + final text = campaignText(14, 500, AppColors.onCosmic, height: 1.4); + Widget photos(List urls, String label) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final url in urls) + Padding( + padding: const EdgeInsets.only(bottom: AppSpace.s2), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Image.network( + url, + fit: BoxFit.cover, + semanticLabel: label, + errorBuilder: (_, _, _) => const SizedBox( + height: 120, + child: Center(child: Icon(Icons.broken_image_outlined)), + ), + ), + ), + ), + ], + ); + return Column( + key: const ValueKey('campaign-report'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(context.l10n.campaignReport, style: heading), + const SizedBox(height: 4), + Text( + relativeTime(report.createdAt), + style: campaignText(12, 500, AppColors.onCosmicMuted), + ), + const SizedBox(height: 12), + photos(report.photoUrls, context.l10n.campaignReportPhotos), + Text(report.body, style: text), + const SizedBox(height: AppSpace.s5), + Text(context.l10n.campaignBudget, style: sub), + const SizedBox(height: AppSpace.s2), + for (final line in report.budget) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Expanded(child: Text(line.label, style: text)), + Text('${formatPop(line.amount)} POP', style: text), + ], + ), + ), + const Divider(), + Row( + children: [ + Expanded(child: Text(context.l10n.campaignTotal, style: sub)), + Text('${formatPop(report.budgetTotal)} POP', style: sub), + ], + ), + const SizedBox(height: AppSpace.s5), + Text(context.l10n.campaignReceipts, style: sub), + const SizedBox(height: AppSpace.s2), + photos(report.receiptUrls, context.l10n.campaignReceipts), + ], + ); + } + + /// 응원 중 · 남은 시간 칸 — #1a1a1c r8 높이 80, 값 20/500 · 라벨 12/500. + Widget _stat(String value, String label) => Expanded( + child: Container( + height: 80, + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + value, + style: campaignText( + 20, + 500, + AppColors.onCosmic, + height: 1.2, + spacing: 0, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: campaignText(12, 500, AppColors.onCosmic, spacing: 0), + ), + ], + ), + ), + ); + + /// 시안 「6일 23:59:06」 — 일 · 시:분:초. + String _left(Duration d) { + String two(int v) => v.toString().padLeft(2, '0'); + final hms = + '${two(d.inHours % 24)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; + return d.inDays > 0 + ? context.l10n.campaignDaysLeft('${d.inDays}', hms) + : hms; + } + + Widget _board() => Padding( + padding: const EdgeInsets.all(20), + child: switch (_translation) { + final t? => TranslationThreads( + repository: t, + rootPath: _root, + builder: (_, byTarget) => _boardBody(t, byTarget), + ), + null => _boardBody(null, const {}), + }, + ); + + /// 댓글(최신순) → 더 보기 → 캠페인 본문의 번역 댓글(시안 `527:21198`). + /// 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에 붙인다(라운지와 같은 배치). + Widget _boardBody( + TranslationRepository? t, + Map> byTarget, + ) { + final own = byTarget[_root] ?? const []; + final comments = _comments; + if (comments == null) { + return const Center(child: CircularProgressIndicator()); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (comments.isEmpty && own.isEmpty) + Text( + context.l10n.commentFirst, + textAlign: TextAlign.center, + style: campaignText(12, 500, AppColors.onCosmicMuted), + ), + for (final (i, c) in comments.indexed) ...[ + if (i > 0) const Divider(height: AppSpace.s5), + _commentCard(c, t), + for (final e in byTarget['$_root/comments/${c.id}'] ?? const []) + Padding( + padding: const EdgeInsets.only(top: AppSpace.s3), + child: TranslationCommentCard( + entry: e, + repository: t!, + reply: true, + ), + ), + ], + if (_moreComments) + TextButton( + onPressed: () => _loadComments(more: true), + child: Text(context.l10n.commonMore), + ), + for (final e in own) + Padding( + padding: const EdgeInsets.only(top: AppSpace.s4), + child: TranslationCommentCard(entry: e, repository: t!), + ), + ], + ); + } + + /// 댓글 카드 — 명세 `527:21192` *좋아요 · 대댓글(번역) · 신고/차단*. + Widget _commentCard(CampaignComment c, TranslationRepository? t) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + c.authorName, + overflow: TextOverflow.ellipsis, + style: campaignText(12, 500, AppColors.onCosmic), + ), + ), + const SizedBox(width: AppSpace.s2), + Text( + relativeTime(c.createdAt), + style: campaignText(10, 500, AppColors.onCosmicMuted), + ), + ], + ), + const SizedBox(height: 4), + Text( + c.body, + style: campaignText(14, 500, AppColors.onCosmic, height: 1.4), + ), + const SizedBox(height: 6), + Row( + children: [ + InkWell( + onTap: () => _likeComment(c), + child: Tooltip( + message: context.l10n.like, + child: Icon( + c.liked ? Icons.favorite : Icons.favorite_border, + size: 14, + color: c.liked + ? AppColors.cosmicAccent + : AppColors.onCosmicMuted, + ), + ), + ), + const SizedBox(width: 4), + Text( + formatReaction(c.likes), + style: campaignText(10, 500, AppColors.onCosmicMuted), + ), + ], + ), + ], + ), + ), + if (t != null) + IconButton( + tooltip: context.l10n.translationWrite, + visualDensity: VisualDensity.compact, + onPressed: () => setState( + () => _replyTo = ( + path: '$_root/comments/${c.id}', + name: c.authorName, + ), + ), + icon: const ImageIcon( + AppIcons.globe, + size: 16, + color: AppColors.onCosmicMuted, + ), + ), + PopupMenuButton( + tooltip: context.l10n.commonMore, + padding: EdgeInsets.zero, + icon: const ImageIcon( + AppIcons.ellipsisVertical, + size: 16, + color: AppColors.onCosmicMuted, + ), + onSelected: (v) => _commentAction(c, v), + itemBuilder: (_) => c.isMine + ? [ + PopupMenuItem( + value: 'delete', + child: Text(context.l10n.commonDeleteAction), + ), + ] + : [ + PopupMenuItem( + value: 'report', + child: Text(context.l10n.reportTitle), + ), + PopupMenuItem( + value: 'block', + child: Text(context.l10n.commonBlockAction), + ), + ], + ), + ], + ); + + /// 두 번 누르면 취소된다. 명세는 +1 만 적었으나 취소가 없으면 오조작을 + /// 되돌릴 수 없다. **저장이 끝난 뒤에만** 숫자를 바꾼다. + Future _like() async { + final on = !_liked; + try { + await widget.repository.like(widget.campaignId, on); + } on Object catch (error) { + _fail(error); + return; + } + if (!mounted) return; + setState(() { + _liked = on; + _likes += on ? 1 : -1; + }); + } + + /// B-58 — 공개 전(검수 중)이거나 공개 후 첫 후원 전이면 개설자가 철회할 수 있다. + bool _withdrawable(CampaignCardData card) => + card.status == CampaignStatus.review || + (card.status == CampaignStatus.funding && + card.supporters == 0 && + _raised == 0); + + /// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환). + Future _withdraw(CampaignCardData card) async { + final beforePublish = card.status == CampaignStatus.review; + final go = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.campaignWithdrawTitle), + content: Text( + beforePublish + ? context.l10n.campaignWithdrawRefund( + formatPop(campaignRegistrationFeePop), + ) + : context.l10n.campaignWithdrawNoRefund( + formatPop(campaignRegistrationFeePop), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(context.l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(context.l10n.campaignWithdrawConfirm), + ), + ], + ), + ); + if (go != true || !mounted) return; + try { + await widget.repository.withdraw(card.id); + } on Object catch (error) { + _fail(error); + return; + } + await _load(); + } + + void _fail(Object error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error is BackendException + ? error.message + : context.l10n.commonRequestFailed, + ), + ), + ); + } + + /// 기여하기 바텀시트 — 명세 `527:21100`. + /// + /// *기여 POP 설정 영역 - 디폴트 0, **보유 POP 이상 설정 불가** ·* + /// *완료 버튼 - 디폴트 비활성화, 기여 POP 설정 후 활성화* + Future _contribute(CampaignDetail detail) async { + final available = _balance - _spent; + final amount = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.7), + builder: (context) => _ContributeSheet(available: available), + ); + if (amount == null || amount <= 0 || !mounted) return; + + // **앱이 잔액을 깎지 않는다.** 원장은 fn-campaign-fund 가 쓴다. + // 실패하면 숫자를 올리지 않고 사유를 알린다 — 성공처럼 그리지 않는다. + final int applied; + try { + applied = await widget.repository.contribute(detail.card.id, amount); + } on Object catch (error) { + _fail(error); + return; + } + if (!mounted) return; + setState(() { + _spent += applied; + _raised += applied; + }); + } + + Future _openRanking(CampaignDetail detail) => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => CampaignRankingScreen( + campaignId: detail.card.id, + repository: widget.repository, + ), + ), + ); + + /// 공유 오버레이 — Figma `480:14594`. + /// + /// 인스타그램 · 틱톡 · X 는 OS 공유 시트를 띄운다. 앱이 대상별 SDK 를 따로 넣지 않아도 + /// 사용자가 설치한 앱 가운데서 고르며, 시안의 버튼 넷은 그대로 남는다. 마지막 「링크 + /// 복사」는 **문구 전문을 클립보드에 담는다** — 웹 브릿지 주소가 설정되면 문구 안에 + /// `/c/<캠페인>` 링크가 들어간다([campaignShareLink]). + /// + /// 명세 — *공유 성공 콜백을 받을 경우 +1*. 지금은 세지 않는다. 공유 수를 올리는 + /// 것은 `campaigns` 문서의 집계 필드를 건드리는 일이고 규칙이 클라이언트 쓰기를 + /// 막아 두었다. 카운터는 캠페인 서버 연결 회차에서 Functions 로 붙는다. + Future _share(CampaignCardData card) async { + final target = await CampaignShareDialog.show( + context, + card: card, + raised: _raised, + ); + switch (target) { + case null: + return; + case CampaignShareTarget.link: + await _copyText(card); + default: + await _shareText(card); + } + } + + String _shareBody(CampaignCardData card) => campaignShareText( + title: card.title, + summary: card.summary.isEmpty ? card.body : card.summary, + fandomName: card.fandomName, + link: campaignShareLink(card.id), + ); + + Future _shareText(CampaignCardData card) async { + await SharePlus.instance.share( + ShareParams(text: _shareBody(card), subject: card.title), + ); + } + + Future _copyText(CampaignCardData card) async { + await Clipboard.setData(ClipboardData(text: _shareBody(card))); + if (mounted) _toast(context.l10n.shareCopied); + } + + /// 캠페인 신고(`reports/campaign-{캠페인}-{신고자}`) · 개설자 차단. + /// 차단하면 그 개설자의 캠페인 · 댓글이 빠지므로 상세를 닫는다. + Future _moderate(String action, CampaignCardData card) async { + try { + if (action == 'report') { + final reason = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget( + authorName: card.authorName, + title: card.title, + ), + ), + ), + ); + if (reason == null) return; + await widget.repository.reportCampaign(card.id, reason); + if (mounted) _toast(context.l10n.reportReceived); + return; + } + if (!await confirmBlock(context, card.authorName)) return; + await widget.repository.blockCreator(card); + if (!mounted) return; + _toast(context.l10n.blocked); + Navigator.pop(context); + } on Object catch (error) { + _fail(error); + } + } + + void _toast(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } +} + +/// 기여 POP 설정 — 명세 `527:21100`. +class _ContributeSheet extends StatefulWidget { + const _ContributeSheet({required this.available}); + + final int available; + + @override + State<_ContributeSheet> createState() => _ContributeSheetState(); +} + +class _ContributeSheetState extends State<_ContributeSheet> { + double _amount = 0; + + // 시안 527:21100 — #1a1a1c r12 카드(안쪽 20 · 간격 14) + 8 + 완료 56(비활성 #333 · #777). + @override + Widget build(BuildContext context) { + final max = widget.available.toDouble(); + final axis = campaignText( + 12, + 400, + const Color(0xFFCDCDCD), + height: 1, + spacing: 0, + family: AppFonts.display, + ); + final ready = _amount >= 1; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.campaignContributeAction, + textAlign: TextAlign.center, + style: campaignText( + 18, + 500, + AppColors.onCosmic, + height: 1.4, + spacing: 0, + ), + ), + const SizedBox(height: 14), + Text( + context.l10n.campaignContributePop, + style: campaignText( + 14, + 600, + AppColors.onCosmic, + spacing: 0, + ), + ), + const SizedBox(height: 14), + Container( + height: 77, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.cosmicVoid, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + formatPop(_amount.round()), + style: campaignText( + 32, + 400, + AppColors.onCosmic, + height: 1.4, + family: AppFonts.display, + ), + ), + ), + const SizedBox(height: 18), + SliderTheme( + data: SliderThemeData( + trackHeight: 6, + padding: EdgeInsets.zero, + activeTrackColor: AppColors.cosmicTeal, + inactiveTrackColor: const Color(0xFF424242), + thumbColor: const Color(0xFF52A0C4), + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 10, + ), + ), + child: SizedBox( + height: 20, + child: Slider( + value: _amount.clamp(0, max), + // 보유 POP 이상은 아예 끌 수 없다. + max: max <= 0 ? 1 : max, + onChanged: max <= 0 + ? null + : (v) => + setState(() => _amount = v.roundToDouble()), + ), + ), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('0', style: axis), + Text(formatPop(widget.available), style: axis), + ], + ), + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text.rich( + TextSpan( + text: context.l10n.commonMyPop, + children: [ + TextSpan( + text: formatPop(widget.available), + style: const TextStyle(color: AppColors.onCosmic), + ), + ], + ), + style: campaignText( + 14, + 500, + AppColors.onCosmicVariant, + height: 1, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + FilledButton( + // 디폴트 비활성화 · 설정 후 활성화. + onPressed: ready + ? () => Navigator.of(context).pop(_amount.round()) + : null, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + backgroundColor: AppColors.onCosmic, + foregroundColor: Colors.black, + disabledBackgroundColor: const Color(0xFF333333), + disabledForegroundColor: const Color(0xFF777777), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + textStyle: campaignText( + 16, + 600, + Colors.black, + height: 1.5, + spacing: 0, + ), + ), + child: Text(context.l10n.commonDone), + ), + ], + ), + ), + ); + } +} + +/// 대표 이미지 위 원형 버튼 — 42 · #000 60%. +class _Round extends StatelessWidget { + const _Round({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => Center( + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: Center(child: child), + ), + ); +} diff --git a/app/lib/features/campaign/campaign_list_screen.dart b/app/lib/features/campaign/campaign_list_screen.dart new file mode 100644 index 0000000..e110680 --- /dev/null +++ b/app/lib/features/campaign/campaign_list_screen.dart @@ -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 createState() => _CampaignListScreenState(); +} + +class _CampaignListScreenState extends State { + final _scroll = ScrollController(); + + List _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 _load() async { + setState(() => _loading = true); + List 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 _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 _open(String id) async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + CampaignDetailScreen(campaignId: id, repository: widget.repository), + ), + ); + if (mounted) await _load(); + } + + Future _search() async { + final id = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => CampaignSearchScreen(repository: widget.repository), + ), + ); + if (id != null && mounted) await _open(id); + } + + Future _create() async { + final id = await Navigator.of(context, rootNavigator: true).push( + 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, + ), + ), + ], + ), + ), + ), + ); +} diff --git a/app/lib/features/campaign/campaign_ranking_screen.dart b/app/lib/features/campaign/campaign_ranking_screen.dart new file mode 100644 index 0000000..f5b14e9 --- /dev/null +++ b/app/lib/features/campaign/campaign_ranking_screen.dart @@ -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 createState() => _CampaignRankingScreenState(); +} + +class _CampaignRankingScreenState extends State { + List _ranks = const []; + RankWindow _window = RankWindow.live; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _pickWindow() async { + final picked = await showModalBottomSheet( + 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, + ), + ], + ), + ), + ); +} diff --git a/app/lib/features/campaign/campaign_repository_firestore.dart b/app/lib/features/campaign/campaign_repository_firestore.dart new file mode 100644 index 0000000..61ac4b7 --- /dev/null +++ b/app/lib/features/campaign/campaign_repository_firestore.dart @@ -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 _queries = []; + + // ponytail: 공개 캠페인을 한 번에 최대 100건 읽어 앱에서 정렬·검색·페이징한다. + // 캠페인이 늘면 (status, createdAt DESC) 인덱스 + 커서 페이징으로 옮긴다. + Future> _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 {} : 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 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> 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> more({ + required int offset, + int limit = 10, + }) async => (await _all()).skip(offset).take(limit).toList(); + + @override + Future 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 _report(String campaignId, DateTime fallback) async { + final m = + (await _backend.db.doc('campaigns/$campaignId/reports/main').get()) + .data(); + if (m == null) return null; + Future> 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) + (label: l.strOr('label'), amount: l.intOr('amount')), + ], + createdAt: m.dateOr('createdAt', fallback), + ); + } + + @override + Future> 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 = {}; + 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> recentQueries() async => [..._queries]; + + @override + Future clearQueries() async => _queries.clear(); + + @override + Future removeQuery(String query) async => _queries.remove(query); + + @override + Future> 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 _pendingSeq = {}; + + /// 후원 — `fn-campaign-fund`. 기여 POP 만 쓴다(정책 원장 B-33). + /// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게. + @override + Future 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 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 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 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> 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 withdraw(String campaignId) async { + await _backend.call('fnCampaignWithdraw', {'campaignId': campaignId}); + } + + /// 개설 — `fn-campaign-create`. 서버가 자격(B-56)을 보고 등록비(B-57)를 뺀 뒤 + /// 심사 대기(`review`)로 올린다. 규칙은 클라이언트 직접 생성을 막는다. + /// 응답이 유실된 재시도는 같은 요청 번호로 보내 등록비가 두 번 빠지지 않게 한다. + @override + Future 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>? _commentCursor; + + /// 내가 차단한 UID. 목록 · 댓글 장을 읽을 때마다 새로 읽는다 — MY 에서 차단을 + /// 풀거나 다른 화면에서 차단해도 저장소 캐시가 낡지 않는다. + Future> _blockedUids() async => { + for (final d + in (await _backend.db + .collection('users/${_backend.requireUid}/blocks') + .get()) + .docs) + d.id, + }; + + /// 최신순 한 장. 차단한 작성자의 댓글이 한 장을 다 채우면 화면이 「끝」으로 + /// 오인하므로 서버에 댓글이 남아 있는 동안 다음 장을 더 읽는다(라운지와 같은 방식). + @override + Future> comments( + String campaignId, { + bool more = false, + }) async { + if (!more) _commentCursor = null; + final uid = _backend.requireUid; + final blocked = await _blockedUids(); + final out = []; + 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 _comment( + DocumentSnapshot> d, + String uid, + ) async { + final data = d.data() ?? const {}; + 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 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 deleteComment(String campaignId, String commentId) => + _backend.db.doc('campaigns/$campaignId/comments/$commentId').delete(); + + /// 댓글 좋아요 — `comments/{cid}/likes/{uid}`. 수는 서버 트리거가 센다. + @override + Future 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 reportComment( + String campaignId, + CampaignComment comment, + String reason, + ) => _fileReport( + 'campaignComment', + '$campaignId-${comment.id}', + 'campaigns/$campaignId/comments/${comment.id}', + reason, + ); + + /// 캠페인 신고 — ID `campaign-{캠페인}-{신고자}`. + @override + Future reportCampaign(String campaignId, String reason) => + _fileReport('campaign', campaignId, 'campaigns/$campaignId', reason); + + Future _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 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 blockAuthor(CampaignComment comment) => + _block(comment.authorUid, comment.authorName); + + /// 개설자 차단 — 댓글 작성자 차단과 같은 목록이라 그 개설자의 댓글도 빠진다. + @override + Future blockCreator(CampaignCardData card) => + _block(card.creatorUid, card.authorName); + + Future _block(String? target, String nickname) async { + if (target == null) { + throw BackendException('E-PERM-01', appL10n.blockUnavailable); + } + await my.block(target, nickname: nickname); + } +} diff --git a/app/lib/features/campaign/campaign_search_screen.dart b/app/lib/features/campaign/campaign_search_screen.dart new file mode 100644 index 0000000..d6952a2 --- /dev/null +++ b/app/lib/features/campaign/campaign_search_screen.dart @@ -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 createState() => _CampaignSearchScreenState(); +} + +class _CampaignSearchScreenState extends State { + final _query = TextEditingController(); + + List _recent = const []; + List? _results; + List _sorted = const []; + CampaignSort _sort = CampaignSort.latest; + + @override + void initState() { + super.initState(); + _loadRecent(); + } + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } + + Future _loadRecent() async { + final recent = await widget.repository.recentQueries(); + if (mounted) setState(() => _recent = recent); + } + + Future _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 _applySort(CampaignSort sort) async { + final results = _results ?? const []; + 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 _create() async { + final id = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => CampaignCreateScreen(repository: widget.repository), + ), + ); + if (id != null && mounted) await _search(_query.text); + } +} diff --git a/app/lib/features/campaign/campaign_share.dart b/app/lib/features/campaign/campaign_share.dart new file mode 100644 index 0000000..a7befcd --- /dev/null +++ b/app/lib/features/campaign/campaign_share.dart @@ -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 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/`. +String? campaignShareLink(String campaignId, {String base = shareBaseUrl}) => + shareLink(['c', campaignId], base: base); + +/// 해시태그를 조립한다. 최대 [maxHashtags] 개다. +/// +/// B-78 — *브랜드 · Artist/Fandom · Campaign/Quest 등을 콘텐츠에 따라 최대 약 +/// 3 개로 동적 구성합니다.* 세 자리를 채우는 것이 아니라 **있는 것만** 넣는다. +/// 팬덤을 모르는 캠페인은 두 개가 되고, 그것이 「고정 3종」이 아니라는 증거다. +List shareHashtags({ + String brand = shareBrand, + String? fandomName, + String? topic, + int maxHashtags = 3, +}) { + final tags = [ + 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 = [ + 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'); +} diff --git a/app/lib/features/campaign/widgets/campaign_gauge.dart b/app/lib/features/campaign/widgets/campaign_gauge.dart new file mode 100644 index 0000000..222e16d --- /dev/null +++ b/app/lib/features/campaign/widgets/campaign_gauge.dart @@ -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, + ), + ), + ), + ], + ), + ); +} diff --git a/app/lib/features/campaign/widgets/campaign_list_card.dart b/app/lib/features/campaign/widgets/campaign_list_card.dart new file mode 100644 index 0000000..c896d1a --- /dev/null +++ b/app/lib/features/campaign/widgets/campaign_list_card.dart @@ -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), + ), + ], + ], + ), + ); +} diff --git a/app/lib/features/campaign/widgets/campaign_period_sheet.dart b/app/lib/features/campaign/widgets/campaign_period_sheet.dart new file mode 100644 index 0000000..82354e4 --- /dev/null +++ b/app/lib/features/campaign/widgets/campaign_period_sheet.dart @@ -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 showCampaignPeriodSheet( + BuildContext context, { + DateTimeRange? initial, + DateTime? today, +}) => showModalBottomSheet( + 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 createState() => _CampaignPeriodSheetState(); +} + +class _CampaignPeriodSheetState extends State { + 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), + ], + ), + ); + } +} diff --git a/app/lib/features/campaign/widgets/campaign_rank.dart b/app/lib/features/campaign/widgets/campaign_rank.dart new file mode 100644 index 0000000..b8e1c99 --- /dev/null +++ b/app/lib/features/campaign/widgets/campaign_rank.dart @@ -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 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; +} diff --git a/app/lib/features/campaign/widgets/campaign_share_dialog.dart b/app/lib/features/campaign/widgets/campaign_share_dialog.dart new file mode 100644 index 0000000..02b52fb --- /dev/null +++ b/app/lib/features/campaign/widgets/campaign_share_dialog.dart @@ -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 show( + BuildContext context, { + required CampaignCardData card, + required int raised, + }) => showGeneralDialog( + 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, + ), + ), + }; +} diff --git a/app/lib/features/campaign/widgets/campaign_style.dart b/app/lib/features/campaign/widgets/campaign_style.dart new file mode 100644 index 0000000..6ef7c3c --- /dev/null +++ b/app/lib/features/campaign/widgets/campaign_style.dart @@ -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); diff --git a/app/lib/features/quest/place_picker_screen.dart b/app/lib/features/quest/place_picker_screen.dart new file mode 100644 index 0000000..5b9bae6 --- /dev/null +++ b/app/lib/features/quest/place_picker_screen.dart @@ -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 places; + + /// 어드민에 등록될 장소의 표본. **정책값이 아니다.** + static const defaultPlaces = [ + '홍대 팝업스토어', + 'HYBE 사옥', + 'SM 코엑스 아티움', + '올림픽공원 체조경기장', + '고척 스카이돔', + ]; + + @override + State createState() => _PlacePickerScreenState(); +} + +class _PlacePickerScreenState extends State { + String _query = ''; + String? _selected; + + List 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( + groupValue: _selected, + onChanged: (v) => setState(() => _selected = v), + child: ListView.builder( + itemCount: _visible.length, + itemBuilder: (context, i) { + final place = _visible[i]; + return RadioListTile( + 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), + ), + ), + ), + ], + ), + ); +} diff --git a/app/lib/features/quest/quest_ad_screens.dart b/app/lib/features/quest/quest_ad_screens.dart new file mode 100644 index 0000000..4247f96 --- /dev/null +++ b/app/lib/features/quest/quest_ad_screens.dart @@ -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 showQuestAdBonus(BuildContext context, {int? bonusPop}) => + showDialog( + 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 _product = + Store.instance?.product(Store.focusMonthly).catchError((_) => null) ?? + Future.value(); + + Future _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( + 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? until; + + @override + State createState() => _QuestAdWarpScreenState(); +} + +class _QuestAdWarpScreenState extends State { + 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, + ), + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart new file mode 100644 index 0000000..48623f6 --- /dev/null +++ b/app/lib/features/quest/quest_create_screen.dart @@ -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 createState() => _QuestCreateScreenState(); +} + +class _QuestCreateScreenState extends State { + 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 _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 _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 []) + _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 _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 _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( + 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 _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 _confirm() async { + final pool = _paid ? _pool : 0; + final fee = _catalog?.feeOf(pool) ?? 0; + final go = await showDialog( + 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart new file mode 100644 index 0000000..8d4f0aa --- /dev/null +++ b/app/lib/features/quest/quest_data.dart @@ -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 quest, Map 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 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 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 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 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> load({int limit = 10}); + + Future> more({required int offset, int limit = 10}); + + Future detail(String questId); + + /// 생성 화면의 분류 · 활동과 운영값 — `config/questCatalog` · `config/policy` 를 매번 읽는다(C-01). + Future catalog(); + + /// 위치 Quest 에 고를 수 있는 등록 장소. + Future> places(); + + /// 링크 미리보기(C-05). 저장 때 서버가 다시 조회하므로 화면 표시용이다. + Future previewLink(String url); + + /// 사람 검토형 인증 제출 · 반려 후 보완 제출(C-08 · G-01). + Future submit(String questId, {String? url, String? text}); + + /// 생성자 검토 — 승인 · 반려(사유 · 재제출 가능 여부). + Future review( + String submissionId, { + required bool approve, + String? reason, + bool resubmitAllowed = false, + }); + + /// 공개 후 수정 — 제목 · 설명만(C-07). + Future edit( + String questId, { + required String title, + required String description, + }); + + /// 상태 전이. 서버가 확정한 상태를 돌려준다. + Future advance(String questId, QuestStage next); + + Future create(QuestDraft draft); + + /// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다. + /// + /// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 + /// 수집* 이 이 스트림이다. 값은 **시작 시점 대비 증가분**이며, 권한이 없거나 + /// 센서가 없으면 오류로 끊는다. + Stream progressStream(QuestCard card); + + /// 활동 증거를 서버에 보내 판정·지급을 받는다. + /// + /// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는 + /// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 · + /// E-QST-03 반경 밖 · E-POP-03 중복). + Future verify(QuestCard card); + + /// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다. + /// + /// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force · + /// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). + Future loadRewardedAd(); + + /// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은 + /// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02). + /// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다. + Future 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 = [ + 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> load({int limit = 10}) async => + _cards.take(limit).map(_withClaim).toList(); + + @override + Future> 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 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 catalog() async => _catalog; + + @override + Future> places() async => [ + for (final name in PlacePickerScreen.defaultPlaces) + QuestPlace(id: name, name: name), + ]; + + /// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다. + @override + Future previewLink(String url) async => QuestLink( + url: url, + status: Uri.tryParse(url)?.hasScheme == true ? 'preview_failed' : 'invalid', + ); + + @override + Future submit(String questId, {String? url, String? text}) async {} + + @override + Future review( + String submissionId, { + required bool approve, + String? reason, + bool resubmitAllowed = false, + }) async {} + + @override + Future edit( + String questId, { + required String title, + required String description, + }) async {} + + /// 목 모드에서 이미 보상을 받은 퀘스트. 서버가 없으니 앱이 기억한다. + /// + /// **표본에만 있는 상태다.** 실 서버에서는 `questProgress` 가 같은 일을 한다. + /// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다. + static final _claimed = {}; + + @override + Future advance(String questId, QuestStage next) async => next; + + @override + Future create(QuestDraft draft) async => 'q-new'; + + /// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌 + /// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다. + @override + Stream progressStream(QuestCard card) => const Stream.empty(); + + @override + Future verify(QuestCard card) async { + _claimed.add(card.id); + return QuestReward( + rewardedPop: card.rewardPop ?? 0, + balanceAfter: 12540 + (card.rewardPop ?? 0), + ); + } + + /// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다. + @override + Future loadRewardedAd() async { + await Future.delayed(const Duration(seconds: 2)); + return const _SampleAd(); + } + + /// 운영 Quest 기본 x2 의 증분(= 기본 보상). 표본이라 한도를 세지 않는다. + @override + Future claimAdReward(String impressionId, QuestReward base) async => + base.rewardedPop; + + /// 표본 상태를 되돌린다. 테스트가 회차마다 같은 자리에서 시작하도록 둔다. + static void resetClaims() => _claimed.clear(); +} + +class _SampleAd implements RewardedAdHandle { + const _SampleAd(); + + @override + Future show() async => 'sample-impression'; + + @override + void dispose() {} +} diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart new file mode 100644 index 0000000..96130b5 --- /dev/null +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -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 createState() => _QuestDetailScreenState(); +} + +class _QuestDetailScreenState extends State { + QuestDetail? _detail; + QuestStage? _stage; + + /// 실측 진행도. 명세 `518:88441` 의 *실시간 목표 실행률 수집* 이다. + /// null 이면 아직 수집을 시작하지 않아 서버가 준 값을 그린다. + int? _live; + StreamSubscription? _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 _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( + 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 _submissionStatus(QuestSubmission s) { + final lines = [ + 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 _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 _review(QuestSubmission s, {required bool approve}) async { + String? reason; + var resubmit = false; + if (!approve) { + final input = TextEditingController(); + final ok = await showDialog( + 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 _submitProof(QuestDetail detail) async { + final url = TextEditingController(); + final text = TextEditingController(); + final ok = await showDialog( + 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 _edit(QuestDetail detail) async { + final title = TextEditingController(text: detail.card.title); + final description = TextEditingController(text: detail.description); + final ok = await showDialog( + 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 _run(Future 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 _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 _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 _confirmStop(QuestDetail detail) async { + final stop = await showDialog( + 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 _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( + MaterialPageRoute( + builder: (_) => + QuestRewardScreen(detail: detail, reward: reward, adBonusPop: adPop), + ), + ); + if (!mounted) return; + // 서버 상태를 다시 읽는다. 걸음 퀘스트는 구간을 하나 받아도 위 구간이 남아 있어 + // (B-26 은 하루에 네 구간이다) 「보상완료」로 굳히면 남은 구간을 받을 수 없다. + await _load(); + } + + /// 광고 시청 전 대기(`494:14332`)를 띄운 채 광고를 불러와 보여 준다. + /// + /// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면 + /// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 · + /// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다. + Future _watchAd() async { + RewardedAdHandle? ad; + BackendException? failure; + final loading = widget.repository.loadRewardedAd().then( + (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 _warp(QuestAdWarpScreen screen) => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => screen), + ); + + Future _report(String title) async { + final reason = await Navigator.of(context, rootNavigator: true).push( + 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, + ), + ), + ), + ], + ), + ); +} diff --git a/app/lib/features/quest/quest_evidence.dart b/app/lib/features/quest/quest_evidence.dart new file mode 100644 index 0000000..0f1bfbf --- /dev/null +++ b/app/lib/features/quest/quest_evidence.dart @@ -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 Function(); + +/// 현재 좌표 한 점을 내는 함수. +typedef PlaceProbe = Future Function(); + +/// 걸음 센서 — `pedometer` 의 `stepCountStream`. +/// +/// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로 +/// 증가분을 낸다([FirestoreQuestRepository.progressStream]). +/// +/// Android 는 API 29 부터 `ACTIVITY_RECOGNITION` 이 런타임 권한이다. 권한이 없으면 +/// 센서 채널이 조용히 빈 스트림으로 남아 「진행률이 0 에서 멈춘다」로만 보이므로, +/// 구독 전에 권한을 먼저 받고 거부 사유를 카탈로그 코드로 올린다. +Stream deviceStepCount() async* { + await _requireActivityPermission(); + yield* Pedometer.stepCountStream.map((event) => event.steps); +} + +Future _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 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 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 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); diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart new file mode 100644 index 0000000..6df835f --- /dev/null +++ b/app/lib/features/quest/quest_list_screen.dart @@ -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 createState() => _QuestListScreenState(); +} + +class _QuestListScreenState extends State { + final _scroll = ScrollController(); + + List _cards = const []; + QuestTab _tab = QuestTab.all; + bool _loading = true; + bool _more = false; + bool _end = false; + + @override + void initState() { + super.initState(); + _scroll.addListener(() { + if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) { + _loadMore(); + } + }); + _load(); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + Future _load() async { + final cards = await widget.repository.load(); + if (!mounted) return; + setState(() { + _cards = cards; + _loading = false; + _end = cards.length < 10; + }); + } + + Future _loadMore() async { + if (_more || _end) return; + setState(() => _more = true); + final next = await widget.repository.more(offset: _cards.length); + if (!mounted) return; + setState(() { + _cards = [..._cards, ...next]; + _more = false; + _end = next.isEmpty; + }); + } + + List get _visible { + // 관리자 퀘스트는 명세대로 완료분을 감춘다 — + // *퀘스트 생성 최신순 소팅 · 진행 완료 퀘스트는 hide*(`518:88395`). + final base = _cards.where( + (c) => !(c.author == QuestAuthor.admin && c.stage == QuestStage.rewarded), + ); + return base.where(_tab.accepts).toList(); + } + + /// 시안 `264:46867` — 「전체」 탭은 관리자 퀘스트를 위 「오늘 꼭 해야하는 퀘스트에요」 + /// 줄로, 사용자 퀘스트를 아래 카드로 나눈다. 다른 탭은 카드만 있다. + bool get _split => _tab == QuestTab.all; + + Future _open(QuestCard card) async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => QuestDetailScreen( + questId: card.id, + repository: widget.repository, + ), + ), + ); + if (mounted) await _load(); + } + + Future _act(QuestCard card) async { + // 사용자 생성 Quest 는 상세에서 링크 · 마감 · 참여 규칙을 본 뒤 참여한다(C-05 · C-06 · C-12). + if (card.stage == QuestStage.mine || card.stage == QuestStage.completed || + card.author == QuestAuthor.user) { + await _open(card); + return; + } + if (card.stage == QuestStage.notStarted) { + // 회신 대기 유형은 목록에서도 참여를 열지 않는다 — 해석 확인 요청서 8.1. + final blocked = card.type.blockedReason; + if (blocked != null) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(blocked), + behavior: SnackBarBehavior.floating, + )); + return; + } + final next = await widget.repository.advance(card.id, QuestStage.inProgress); + if (!mounted) return; + setState(() { + _cards = [ + for (final c in _cards) + if (c.id == card.id) c.copyWith(stage: next) else c, + ]; + }); + return; + } + // 진행중 — 명세는 *퀘스트 포기 알림 노출* 이다. + await _open(card); + } + + Future _report(QuestCard card) async { + final reason = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget(authorName: card.title, title: context.l10n.questNamed(card.title)), + ), + ), + ); + if (reason != null && mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(context.l10n.reportReceived), + behavior: SnackBarBehavior.floating, + )); + } + } + + Widget _tile(QuestCard card) => QuestCardTile( + card: card, + onOpen: () => _open(card), + onAction: () => _act(card), + // 시안은 사용자가 만든 퀘스트 카드에만 ⋮ 를 둔다. 내 퀘스트는 상세로 간다. + onMore: card.author != QuestAuthor.user + ? null + : card.stage == QuestStage.mine + ? () => _open(card) + : () => _report(card), + ); + + List _body() { + final visible = _visible; + final rows = _split + ? visible.where((c) => c.author == QuestAuthor.admin).toList() + : const []; + final cards = _split + ? visible.where((c) => c.author != QuestAuthor.admin).toList() + : visible; + return [ + if (rows.isNotEmpty) ...[ + Text( + context.l10n.questDailyHelp, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.4, + letterSpacing: -0.35, + color: AppColors.cosmicAccent, + ), + ), + const SizedBox(height: 6), + Text( + context.l10n.questTodayMust, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + height: 24 / 22, + letterSpacing: -0.55, + color: AppColors.onCosmic, + ), + ), + const SizedBox(height: 24), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.md), + child: Column( + children: [ + for (final (i, card) in rows.indexed) + QuestAdminRow( + card: card, + onOpen: () => _open(card), + divider: i < rows.length - 1, + ), + ], + ), + ), + if (cards.isNotEmpty) ...[ + const SizedBox(height: 24), + const Divider(height: 1, thickness: 1, color: Color(0xFF242424)), + const SizedBox(height: 24), + ], + ], + for (final (i, card) in cards.indexed) ...[ + if (i > 0) const SizedBox(height: 16), + _tile(card), + ], + if (_more) + const Padding( + padding: EdgeInsets.all(AppSpace.s4), + child: Center(child: CircularProgressIndicator()), + ), + ]; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: [ + // 시안 — 번개 아이콘 + 보유 POP. 민트 10% 채움 · 민트 1px 테두리. + Padding( + padding: const EdgeInsets.only(right: AppSpace.s4), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.cosmicMint.withValues(alpha: 0.10), + border: Border.all(color: AppColors.cosmicMint), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/figma/icon-bolt.png', width: 10, height: 10.4), + const SizedBox(width: 5), + Text( + formatPop(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 _create() async { + final id = await Navigator.of(context, rootNavigator: true).push( + 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 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), + ), + ), + ), + ), + ), + ], + ), + ); +} diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart new file mode 100644 index 0000000..a38c902 --- /dev/null +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -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>? _cursor; + + /// 오늘 걸은 수. 마지막으로 센서에서 받은 값이며 서버에 그대로 보낸다. + int _walkSteps = 0; + + @override + Future> load({int limit = 10}) async { + _cursor = null; + return _page(limit); + } + + @override + Future> more({required int offset, int limit = 10}) => + _page(limit); + + Future> _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 = []; + 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 {}); + 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 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 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 quest, + Map progress, + String uid, + Map 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 _stepTiers(Map policy) { + final raw = policy['stepTiers']; + if (raw is List && raw.isNotEmpty) { + final steps = []; + 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 quest) { + final raw = quest['title']; + if (raw is String) return raw; + if (raw is Map) { + final map = Map.from(raw); + return map.strOr('ko', map.strOr('en')); + } + return ''; + } + + Future> _policy() async { + final snap = await _backend.db.doc('config/policy').get(); + return snap.data() ?? const {}; + } + + @override + Future 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 {}; + final progress = await db + .doc('users/$uid/questProgress/$questId') + .get() + .then((s) => s.data() ?? const {}); + 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.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.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 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 m) { + final evidence = m['evidence'] is List ? m['evidence'] as List : const []; + final last = evidence.isNotEmpty && evidence.last is Map + ? Map.from(evidence.last as Map) + : const {}; + 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 _submission(String questId, Map 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> _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 catalog() async { + final raw = await _backend.db + .doc('config/questCatalog') + .get() + .then((s) => s.data() ?? const {}); + final policy = await _policy(); + List> rows(String key) => [ + for (final r in (raw[key] is List ? raw[key] as List : const [])) + if (r is Map && r['visible'] != false) Map.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 = []; + 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> 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 previewLink(String url) async => + _link(await _backend.call('fnLinkPreview', {'url': url})); + + @override + Future 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 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 edit(String questId, {required String title, required String description}) => + _backend.call('fnQuestEdit', { + 'questId': questId, + 'title': title, + 'description': description, + }); + + /// 시작·중단은 세션 상태다. + /// + /// 설계서의 `questProgress` 는 **당일 수행 횟수와 지급액**만 담는다(§2.1). 참여 + /// 시작 시각을 남기는 필드가 없어 서버에 「진행중」이 존재하지 않는다. 보상 상태는 + /// [verify] 가 지급에 성공한 뒤 서버 카운터로 확정된다. + @override + Future advance(String questId, QuestStage next) async => next; + + /// 생성 · 예치 · 수수료 · 공개는 `fn-quest-create` 가 원장과 함께 한다(B-28 · B-30). 앱은 + /// `quests` 에 직접 쓰지 않는다(규칙도 마스터 전용). 종료일은 **생성자 기기 시간대의 그 날짜 + /// 23:59:59** 를 서버가 UTC 로 굳히도록 날짜와 그 시각의 UTC 오프셋을 보낸다(C-06). + @override + Future 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 progressStream(QuestCard card) { + if (card.type != QuestType.steps) return const Stream.empty(); + // 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의 + // 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다. + return _stepCount().asyncMap((cumulative) async { + _walkSteps = await _baseline.todaySteps(cumulative); + return _walkSteps; + }); + } + + @override + Future 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 loadRewardedAd() => loadGoogleRewardedAd(); + + @override + Future 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> _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> _verifyPlace(QuestCard card) async { + final quest = await _backend.db + .doc('quests/${card.id}') + .get() + .then((s) => s.data() ?? const {}); + 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 quest) { + final direct = quest.strOr('placeId'); + if (direct.isNotEmpty) return direct; + final conditions = quest['conditions']; + if (conditions is Map) { + return Map.from(conditions).strOr('placeId'); + } + return ''; + } + + /// 활동 로그의 집계 축 — `activityLogs.fandomId`(설계서 §2.1). 소속이 없으면 싣지 않는다. + Future> _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 {}); + 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); diff --git a/app/lib/features/quest/quest_reward_screen.dart b/app/lib/features/quest/quest_reward_screen.dart new file mode 100644 index 0000000..ae2de51 --- /dev/null +++ b/app/lib/features/quest/quest_reward_screen.dart @@ -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), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/quest/widgets/quest_card_tile.dart b/app/lib/features/quest/widgets/quest_card_tile.dart new file mode 100644 index 0000000..f36de84 --- /dev/null +++ b/app/lib/features/quest/widgets/quest_card_tile.dart @@ -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), + ), + ], + ), + ), + ), + ); +} diff --git a/app/lib/features/quest/widgets/time_attack_countdown.dart b/app/lib/features/quest/widgets/time_attack_countdown.dart new file mode 100644 index 0000000..2379207 --- /dev/null +++ b/app/lib/features/quest/widgets/time_attack_countdown.dart @@ -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 createState() => _TimeAttackCountdownState(); +} + +class _TimeAttackCountdownState extends State { + 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'; +}