Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림

This commit is contained in:
Sn-Kinos
2026-10-06 03:03:20 +09:00
parent 64825e72b0
commit ecf5998725
77 changed files with 2703 additions and 640 deletions
+73 -21
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@@ -8,10 +9,13 @@ import 'package:latlong2/latlong.dart';
import '../../core/theme/tokens.dart';
import '../../core/i18n/app_locale.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」).
///
/// 지도를 누르면 그 자리에 핀을 꽂고 주소를 보여 준다. 운영이 등록한 장소는 지도 위 표시와
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
/// 운영이 등록한 장소는 지도 위 표시와
/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안:
/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
///
@@ -42,6 +46,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
String _query = '';
QuestPlace? _selected;
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
bool _resolving = false;
Timer? _settle;
List<QuestPlace> get _visible => widget.places
.where((p) => _query.isEmpty || p.name.contains(_query))
.toList();
@@ -53,25 +61,65 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
return PlacePickerScreen.fallbackCenter;
}
@override
void initState() {
super.initState();
_locate();
}
@override
void dispose() {
_settle?.cancel();
super.dispose();
}
/// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(등록 장소 · 서울시청)에 둔다 —
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
Future<void> _locate() async {
try {
final fix = await devicePlaceFix();
if (!mounted || _selected != null) return;
final here = LatLng(fix.lat, fix.lng);
_map.move(here, _map.camera.zoom);
await _drop(here);
} catch (_) {
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
}
}
void _choose(QuestPlace place) {
setState(() => _selected = place);
_settle?.cancel();
setState(() {
_selected = place;
_resolving = false;
});
if (place.lat != null && place.lng != null) {
_map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom);
}
}
/// 지도를 누른 자리에 핀 — 주소는 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
/// 사용자가 지도를 움직이면 멈춘 뒤 가운데 좌표로 위치를 정한다(제스처가 아닌 이동 — 등록 장소
/// 선택 · 현재 위치 이동 — 은 무시).
void _moved(MapCamera camera, bool hasGesture) {
if (!hasGesture) return;
_settle?.cancel();
setState(() => _resolving = true);
_settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center));
}
/// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
Future<void> _drop(LatLng point) async {
final coords =
'${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
setState(
() => _selected = QuestPlace(
setState(() {
_resolving = true;
_selected = QuestPlace(
id: '',
name: coords,
lat: point.latitude,
lng: point.longitude,
),
);
);
});
var address = coords;
try {
final marks = await Geocoding().placemarkFromCoordinates(
@@ -86,14 +134,15 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
}
if (!mounted || _selected?.lat != point.latitude) return;
setState(
() => _selected = QuestPlace(
setState(() {
_resolving = false;
_selected = QuestPlace(
id: '',
name: address,
lat: point.latitude,
lng: point.longitude,
),
);
);
});
}
@override
@@ -140,7 +189,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
options: MapOptions(
initialCenter: _center,
initialZoom: 15,
onTap: (_, point) => _drop(point),
onPositionChanged: _moved,
),
children: [
TileLayer(
@@ -163,17 +212,20 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
),
),
),
if (pinned != null &&
pinned.lat != null &&
pinned.lng != null)
Marker(
point: LatLng(pinned.lat!, pinned.lng!),
],
),
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
const IgnorePointer(
child: Align(
child: Padding(
padding: EdgeInsets.only(bottom: 44),
child: SizedBox(
width: 38,
height: 44,
alignment: Alignment.topCenter,
child: const CustomPaint(painter: _PinPainter()),
child: CustomPaint(painter: _PinPainter()),
),
],
),
),
),
// OSM 타일 이용 조건 — 출처 표기.
const Align(
@@ -227,7 +279,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: pinned == null
onPressed: pinned == null || _resolving
? null
: () => Navigator.of(context).pop(pinned),
style: FilledButton.styleFrom(
+26 -2
View File
@@ -277,11 +277,25 @@ class QuestAdWarpScreen extends StatefulWidget {
State<QuestAdWarpScreen> createState() => _QuestAdWarpScreenState();
}
class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
class _QuestAdWarpScreenState extends State<QuestAdWarpScreen>
with SingleTickerProviderStateMixin {
String _message(BuildContext context) =>
widget.message ??
(widget._after ? context.l10n.adWarpAfter : context.l10n.adWarpBefore);
/// 명세 `518:88645` — 시청 전 *광고 시청을 북돋을 수 있는 짧은 애니메이션*, 시청 후 *서비스에
/// 재몰입 할 수 있는 애니메이션*. 행성이 한 번 커지며 반 바퀴 돈다(한 번만 — 반복하지 않는다).
late final AnimationController _spin = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..forward();
@override
void dispose() {
_spin.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
@@ -307,7 +321,17 @@ class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212),
AnimatedBuilder(
animation: _spin,
builder: (_, child) {
final t = Curves.easeOutBack.transform(_spin.value);
return Transform.rotate(
angle: (widget._after ? -1 : 1) * (1 - _spin.value) * 3.14159,
child: Transform.scale(scale: 0.8 + 0.2 * t, child: child),
);
},
child: Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212),
),
const SizedBox(height: 24),
// 시안은 405 폭 한 줄이라 375 화면 밖으로 잘린다 — 좌우 16 안에서 가운데 두 줄로.
// 행간은 한 줄이면 사본 값 1, 두 줄로 넘어갈 때만 1.4.
@@ -11,6 +11,7 @@ import 'quest_data.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
import '../../core/ui/sheet_route.dart';
/// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`.
///
@@ -51,6 +52,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
QuestLink? _link;
bool _checkingLink = false;
bool _saving = false;
int? _myPop;
@override
void initState() {
@@ -75,6 +77,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
}
Future<void> _load() async {
widget.repository.spendableBalance().then((v) {
if (mounted) setState(() => _myPop = v);
}, onError: (_) {});
final catalog = await widget.repository.catalog();
if (mounted) setState(() => _catalog = catalog);
}
@@ -107,6 +112,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
? context.l10n.questPreviewHint
: _title.text.trim(),
categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory,
// 명세 `523:18123` 미리보기 — *입력 상태에 따라 실시간 반영*. 고른 활동의 아이콘을 바로 보인다.
type: _activity == null ? QuestType.unknown : QuestType.parse(_activity!.key),
author: QuestAuthor.user,
stage: QuestStage.mine,
goal: _needs('goal') ? _intOf(_goal) : 1,
@@ -303,7 +310,12 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.place_outlined),
title: Text(_place?.name ?? context.l10n.questPickPlace),
// 명세 `523:18270` — *텍스트 넘칠 경우 ... 처리*.
title: Text(
_place?.name ?? context.l10n.questPickPlace,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right),
onTap: _pickPlace,
),
@@ -380,6 +392,20 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
),
style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant),
),
// 명세 `523:18176` 완료 보상 설정 영역 — *보유 POP 값*.
if (_myPop != null)
Text.rich(
TextSpan(
text: context.l10n.commonMyPop,
children: [
TextSpan(
text: formatPop(_myPop!),
style: const TextStyle(color: AppColors.cosmicBlue),
),
],
),
style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant),
),
] else
Text(
context.l10n.questNoReward,
@@ -422,7 +448,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
_places = places;
}
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
MaterialPageRoute(builder: (_) => PlacePickerScreen(places: _places)),
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
SheetRoute(builder: (_) => PlacePickerScreen(places: _places)),
);
if (picked == null || !mounted) return;
setState(() => _place = picked);
@@ -448,8 +475,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
final go = await showConfirmDialog(
context,
context.l10n.questCreateConfirm,
cancelLabel: context.l10n.commonKeepEditing,
confirmLabel: context.l10n.questCreate,
// 명세 `523:18176` 생성 안내 모달 — *취소 / 완료*(시안 `548:19548` · `548:19552`).
cancelLabel: context.l10n.commonCancel,
confirmLabel: context.l10n.commonDone,
body: context.l10n.questCreateCost(
_preview.title,
formatPop(_intOf(_reward)),
+46 -3
View File
@@ -105,6 +105,18 @@ enum QuestType {
_ => QuestType.unknown,
};
/// 목록 · 상세의 **카테고리** 값 — 명세 `518:88395` · `518:88417` *퀘스트 카테고리 - 등록된 카테고리값*
/// (시안 「건강」 · 「커뮤니티」 · 「출석체크」). 활동이 속한 분류는 `config/questCatalog` 기본값
/// (`functions/src/quest/ugc.ts` — walk · shake → health, vote · sns · streaming → planet …)과 같다.
/// 출석 · 타임어택은 카탈로그 분류가 없어 유형 이름을 쓴다.
String get category => switch (this) {
steps || musicShake => appL10n.questCatHealth,
streaming || vote || sns => appL10n.questCatPlanet,
community => appL10n.questCatCommunity,
place => appL10n.questTypePlace,
_ => label,
};
/// 사람 검토형(C-08) — 참여가 곧 지급이 아니라 제출이다.
bool get reviewed => this == QuestType.vote || this == QuestType.sns;
@@ -315,6 +327,14 @@ class QuestSubmission {
now.difference(submittedAt!) > Duration(hours: hours);
}
/// 사용자 생성 Quest 의 모집 인원이 다 찼는가 — 명세 `523:18176` *참여 인원 충족 시 퀘스트 종료*.
/// 서버 참여 수(`participantCount`)는 지급 · 검토 예약 때 오르고 최종 반려 때 내려간다.
bool questFull(QuestCard card) =>
card.author == QuestAuthor.user &&
card.capacity != null &&
card.capacity! > 0 &&
card.participants >= card.capacity!;
/// 목록 카드 하나.
class QuestCard {
const QuestCard({
@@ -333,12 +353,17 @@ class QuestCard {
this.ugcAdPop = 10,
this.endsAt,
this.publishState = '',
this.authorName = '',
});
final String id;
final String title;
final String categoryName;
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
final String authorName;
/// 내가 만든 Quest 의 공개 상태(`quests.status`) — `review` · `published` · `rejected` 등.
/// 「내 퀘스트」 카드가 검수 중 · 반려를 공개된 것과 구분해 보인다(10/6 실기기 4-2).
final String publishState;
@@ -398,6 +423,7 @@ class QuestCard {
adMultiplier: adMultiplier,
ugcAdPop: ugcAdPop,
endsAt: endsAt,
authorName: authorName,
publishState: publishState,
);
}
@@ -508,9 +534,11 @@ enum QuestTab {
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,
// 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*.
// 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다.
QuestTab.inProgress =>
card.stage == QuestStage.inProgress || card.stage == QuestStage.completed,
QuestTab.done => card.stage == QuestStage.rewarded,
QuestTab.mine => card.stage == QuestStage.mine,
};
}
@@ -610,6 +638,15 @@ abstract interface class QuestRepository {
/// 모르면 null(헤더는 받은 값을 유지한다).
Future<int?> popBalance();
/// 퀘스트 생성에 쓸 수 있는 보유 POP(일반 + 기여 — 서버 예치 `spendSplit` 과 같은 합). 명세
/// `523:18176` 완료 보상 설정 영역 *보유 POP 값*. 모르면 null.
Future<int?> spendableBalance();
/// 오늘 광고 추가 보상을 더 받을 수 있는가(일 한도 — B-03 · 10/3 통합회신 4절 「일 5회」).
/// 별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」 — 다 쓴 사람에게는 보상 안내 모달을 띄우지 않는다.
/// 판정은 서버(`fnAdReward`)가 다시 한다. 읽지 못하면 true(모달을 막지 않는다).
Future<bool> adAvailable();
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
///
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
@@ -852,6 +889,12 @@ class SampleQuestRepository implements QuestRepository {
@override
Future<int?> popBalance() async => null;
@override
Future<int?> spendableBalance() async => null;
@override
Future<bool> adAvailable() async => true;
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
@override
+39 -14
View File
@@ -26,6 +26,7 @@ import 'quest_reward_screen.dart';
import 'widgets/quest_card_tile.dart';
import 'widgets/time_attack_countdown.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/sheet_route.dart';
/// 퀘스트 상세 — 화면 대조 원장 F4.
///
@@ -118,6 +119,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
return Scaffold(
appBar: AppBar(
actions: [
// 명세 `548:19275` — 더보기는 *사용자 퀘스트 중 타인 퀘스트에서만 노출*. 내 퀘스트의
// 수정 · 취소는 확정 정책(C-07 · B-30)이 더한 항목이라 생성자에게도 둔다. 운영 Quest 는 없음.
if (ugc)
PopupMenuButton<String>(
tooltip: context.l10n.commonMore,
icon: const ImageIcon(
@@ -128,7 +132,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
onSelected: (value) => switch (value) {
'edit' => _edit(detail),
'cancel' => _cancel(detail),
_ => _report(card.title),
_ => _report(card),
},
itemBuilder: (_) => [
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
@@ -146,10 +150,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
value: 'cancel',
child: Text(context.l10n.questCancel),
),
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
if (stage != QuestStage.mine)
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
],
),
],
@@ -406,9 +411,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
detail,
stage,
blocked,
// 정원이 찬 퀘스트도 종료다(명세 `523:18176` 종료 시점 1) — 시작 전인 사람만 막힌다.
closed:
detail.endsAt != null &&
!ServerClock.now().isBefore(detail.endsAt!),
(detail.endsAt != null &&
!ServerClock.now().isBefore(detail.endsAt!)) ||
(questFull(card) && stage == QuestStage.notStarted),
)
: TimeAttackCountdown(
endsAt: endsAt,
@@ -959,9 +966,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
// 광고가 실패해도 기본 보상은 그대로다.
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
false;
// 일 한도를 다 쓴 사람에게는 모달을 띄우지 않는다(별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」) —
// 띄우면 광고를 끝까지 보고도 증분 0 이다.
final adOpen = await widget.repository.adAvailable();
if (!mounted) return;
final bonus = adOpen &&
(await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
false);
if (!mounted) return;
int? adPop;
if (bonus) {
@@ -979,6 +990,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
if (mounted) _toast(error.message);
}
if (!mounted) return;
} else if (_adShown) {
// 명세 `518:88645` — 광고 종료(100% 시청 또는 skip) 후 *광고 시청 후 애니메이션*. 끝까지
// 보지 않았으니 증분은 없다(B-03) — 애니메이션만 거쳐 완료 화면으로 간다.
await _warp(QuestAdWarpScreen.after(
until: Future<void>.delayed(const Duration(milliseconds: 1200)),
));
if (!mounted) return;
}
}
@@ -1003,7 +1021,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
/// 이번 보상에서 광고가 실제로 화면에 떴는가 — skip 으로 닫았을 때도 시청 후 애니메이션을 거친다.
bool _adShown = false;
Future<String?> _watchAd(QuestReward reward) async {
_adShown = false;
RewardedAdHandle? ad;
BackendException? failure;
final loading = widget.repository
@@ -1030,6 +1052,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
}
return null;
}
_adShown = true;
final impression = await loaded.show();
if (impression == null && mounted) {
_toast(context.l10n.adNotFinished);
@@ -1065,14 +1088,16 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
rootNavigator: true,
).push<void>(MaterialPageRoute(builder: (_) => screen));
Future<void> _report(String title) async {
Future<void> _report(QuestCard card) async {
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
SheetRoute(
builder: (_) => ReportScreen(
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*. 작성자 칸에 제목이 들어가던 것을 바로잡음.
target: ModerationTarget(
authorName: title,
title: context.l10n.questNamed(title),
authorName: card.authorName,
title: card.title,
),
),
),
@@ -9,6 +9,7 @@ import 'quest_detail_screen.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
import '../../core/ui/sheet_route.dart';
/// 퀘스트 목록 — 화면 대조 원장 F4.
///
@@ -174,9 +175,11 @@ class _QuestListScreenState extends State<QuestListScreen> {
Future<void> _report(QuestCard card) async {
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
SheetRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(authorName: card.title, title: context.l10n.questNamed(card.title)),
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*(작성자 칸에 제목이 들어가던 것을 바로잡음).
target: ModerationTarget(authorName: card.authorName, title: card.title),
),
),
);
@@ -381,11 +384,14 @@ class _QuestListScreenState extends State<QuestListScreen> {
Future<void> _create() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
// 명세 `523:18123` — 닫기는 *하단 move out 페이지 종료*. 아래에서 올라와 아래로 닫힌다.
SheetRoute(
builder: (_) => QuestCreateScreen(repository: widget.repository),
),
);
if (id != null && mounted) {
// 명세 `523:18176` 생성 안내 모달 — *완료 - 퀘스트 Create 후 이전 퀘스트의 내 퀘스트 탭으로 이동*.
setState(() => _tab = QuestTab.mine);
// 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8).
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
@@ -133,7 +133,12 @@ class FirestoreQuestRepository implements QuestRepository {
} on Exception {
progress = const {};
}
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
final card = _card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest));
// 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면
// 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다.
// 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다.
if (questFull(card) && card.stage == QuestStage.notStarted) continue;
cards.add(card);
}
return cards;
}
@@ -198,7 +203,7 @@ class FirestoreQuestRepository implements QuestRepository {
return QuestCard(
id: id,
title: questTitle(quest),
categoryName: type.label,
categoryName: type.category,
type: type,
author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user,
stage: authorUid == uid
@@ -223,6 +228,7 @@ class FirestoreQuestRepository implements QuestRepository {
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
endsAt: openTimeAttack(quest)?.endsAt,
publishState: authorUid == uid ? quest.strOr('status') : '',
authorName: quest.strOr('authorName'),
);
}
@@ -513,6 +519,29 @@ class FirestoreQuestRepository implements QuestRepository {
return wallet.data()?.intOr('activityBalance');
}
@override
Future<bool> adAvailable() async {
try {
final db = _backend.db;
final ad = (await db.doc('users/${_backend.requireUid}/questProgress/ad').get()).data();
// 서버 `progress(uid, 'ad', dayKey(now))` 와 같은 규약 — KST 날짜가 다르면 오늘 0회.
final today = ServerClock.now().toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
if (ad == null || ad['dayKey'] != today) return true;
final limit = (await _policy()).intOr('adDailyLimit', 5);
return ad.intOr('count') < limit;
} on Exception {
return true;
}
}
@override
Future<int?> spendableBalance() async {
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
final data = wallet.data();
if (data == null) return null;
return data.intOr('activityBalance') + data.intOr('supportBalance');
}
@override
Stream<int> progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream<int>.empty();
@@ -59,7 +59,16 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
QuestDetail get detail => widget.detail;
int? get adBonusPop => widget.adBonusPop;
int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop;
/// 큰 숫자 — 운영 Quest 에서 광고를 봤으면 기본 보상 + 광고 증분 합계(명세 `518:88685` *광고 시청
/// POP = 보상 POP × 2 or ×3*, 배수는 B-03 운영값). 사용자 생성 Quest 는 배수가 아니라 고정 증분
/// (B-28 · C-02)이라 기본 보상만 크게 둔다. 두 값 모두 서버 지급액이고, 증분은 아래 줄에 기여 POP 으로
/// 따로 적는다(C-67 · C-122 유형 구분).
int? get rewardPop {
final base = widget.reward?.rewardedPop ?? detail.card.rewardPop;
final ad = widget.adBonusPop;
if (base == null || ad == null || detail.card.author != QuestAuthor.admin) return base;
return base + ad;
}
/// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다.
Future<void> _share(BuildContext button) async {
@@ -198,7 +207,10 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: () => Navigator.of(context).pop(true),
// 설명 프레임 `518:88326` 「확인 버튼 - 클릭 시, 홈 화면 이동」 — 퀘스트 상세 · 목록까지
// 닫고 첫 화면(탭 셸)으로 돌아간다. 퀘스트는 HOME 에서만 열린다.
onPressed: () =>
Navigator.of(context).popUntil((route) => route.isFirst),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(