Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user