광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원
This commit is contained in:
@@ -35,7 +35,13 @@ abstract interface class RewardedAdHandle {
|
||||
///
|
||||
/// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 —
|
||||
/// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
|
||||
///
|
||||
/// [userId] · [customData] 는 **서버측 시청 검증(AdMob SSV)** 에 실린다 — Google 이 시청 완료를
|
||||
/// 서버(`fn-ad-ssv`)로 직접 알릴 때 이 두 값이 「누가 · 어느 기본 보상(txId)에 붙여」 본 광고인지
|
||||
/// 가리킨다. 서버는 그 기록이 있어야만 증분을 지급한다(앱의 보상 콜백은 근거가 아니다).
|
||||
Future<RewardedAdHandle> loadGoogleRewardedAd({
|
||||
required String userId,
|
||||
required String customData,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
await (_initialized ??= MobileAds.instance.initialize());
|
||||
@@ -49,7 +55,11 @@ Future<RewardedAdHandle> loadGoogleRewardedAd({
|
||||
),
|
||||
);
|
||||
try {
|
||||
return _GoogleRewardedAd(await loaded.future.timeout(timeout));
|
||||
final ad = await loaded.future.timeout(timeout);
|
||||
await ad.setServerSideOptions(
|
||||
ServerSideVerificationOptions(userId: userId, customData: customData),
|
||||
);
|
||||
return _GoogleRewardedAd(ad);
|
||||
} catch (_) {
|
||||
// 시간 초과 뒤에 늦게 도착한 광고는 띄우지 않고 버린다.
|
||||
unawaited(loaded.future.then((ad) => ad.dispose(), onError: (_) {}));
|
||||
@@ -75,7 +85,7 @@ class _GoogleRewardedAd implements RewardedAdHandle {
|
||||
try {
|
||||
await _ad.show(
|
||||
onUserEarnedReward: (_, _) {
|
||||
// 서버가 이 값으로 같은 노출의 이중 지급을 막는다(`adRewardLogs/{id}`).
|
||||
// 화면 분기용(끝까지 봤는가)일 뿐 지급 근거가 아니다 — 근거는 SSV 콜백이다.
|
||||
impression = _ad.responseInfo?.responseId ??
|
||||
'gma-${DateTime.now().microsecondsSinceEpoch}';
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:flutter/painting.dart' show Color;
|
||||
import 'home_data.dart';
|
||||
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
|
||||
import '../quest/quest_data.dart' show QuestType;
|
||||
import '../quest/quest_repository_firestore.dart' show questToday;
|
||||
import '../quest/quest_repository_firestore.dart' show openTimeAttack, questToday;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
|
||||
@@ -124,10 +124,9 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
? null
|
||||
: db
|
||||
.collection('quests')
|
||||
.where('type', isEqualTo: 'timeattack')
|
||||
.where('fandomId', isEqualTo: fandomId)
|
||||
.where('timeAttack.fandomId', isEqualTo: fandomId)
|
||||
.where(
|
||||
'activeTo',
|
||||
'timeAttack.activeTo',
|
||||
isGreaterThan: Timestamp.fromDate(
|
||||
DateTime.now().subtract(const Duration(hours: 1)),
|
||||
),
|
||||
@@ -194,16 +193,17 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
if (doc.data()['active'] != false) doc,
|
||||
];
|
||||
if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend);
|
||||
final serverNow = ServerClock.now();
|
||||
final open = [
|
||||
for (final d in timeAttacks)
|
||||
if (openTimeAttack(d.data()) != null) d,
|
||||
];
|
||||
final openIds = {for (final d in open) d.id};
|
||||
final quests = [
|
||||
...timeAttacks.where((d) {
|
||||
final to = d.data()['activeTo'];
|
||||
return to is Timestamp && to.toDate().isAfter(serverNow);
|
||||
}),
|
||||
// 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다.
|
||||
...open,
|
||||
// 회차가 열린 Quest 는 위에서 맨 앞에 넣었다. 두 번 넣지 않는다.
|
||||
...adminQuestsLatest([
|
||||
for (final d in (await questsF).docs)
|
||||
if (d.data()['type'] != 'timeattack') d,
|
||||
if (!openIds.contains(d.id)) d,
|
||||
], (d) => d.data()),
|
||||
].take(3).toList();
|
||||
final progress = (await progressF).docs;
|
||||
@@ -374,7 +374,8 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
title: quest.strOr('title'),
|
||||
type: QuestType.parse(quest.strOr('type')),
|
||||
category: QuestType.parse(quest.strOr('type')).label,
|
||||
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
||||
rewardPop: openTimeAttack(quest)?.rewardPop ??
|
||||
QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
||||
state: done
|
||||
? QuestState.done
|
||||
: started
|
||||
@@ -384,9 +385,7 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
? null
|
||||
: quest.strOr('progressLabel'),
|
||||
id: id,
|
||||
endsAt: quest['type'] == 'timeattack' && quest['activeTo'] is Timestamp
|
||||
? (quest['activeTo'] as Timestamp).toDate()
|
||||
: null,
|
||||
endsAt: openTimeAttack(quest)?.endsAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ enum PopTxType {
|
||||
title: appL10n.popReasonQuestPoolReturn,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'QUEST_FEE_REFUND' => (
|
||||
title: appL10n.popReasonQuestFeeRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'QUEST_TIMEATTACK' => (
|
||||
title: appL10n.popReasonQuestTimeAttack,
|
||||
type: appL10n.popTypeQuest,
|
||||
|
||||
@@ -1,139 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import 'quest_data.dart';
|
||||
|
||||
/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`.
|
||||
/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」).
|
||||
///
|
||||
/// 명세 `567:19531` — *분류 특정 위치를 선택했을 경우 목표 수치(특정 위치) 필드 활성화*.
|
||||
/// 지도를 누르면 그 자리에 핀을 꽂고 주소를 보여 준다. 운영이 등록한 장소는 지도 위 표시와
|
||||
/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안:
|
||||
/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
|
||||
///
|
||||
/// **지도를 붙이지 않았다.** 지오펜스 반경(D-08)과 대상 장소 등록이 모두 Admin
|
||||
/// 운영값이고 아직 확정되지 않았다. 지금 지도 SDK 를 넣으면 최소 지원 OS 가
|
||||
/// 다시 움직인다(플러그인 선검증 §3.5). **장소 선택 동선은 여기서 완결**되고,
|
||||
/// 지도 표시는 대상 장소가 확정된 뒤 이 화면 안에서 바뀐다.
|
||||
/// 돌려주는 값: 등록 장소면 그 [QuestPlace], 핀이면 `id` 가 빈 [QuestPlace](좌표 · 주소).
|
||||
class PlacePickerScreen extends StatefulWidget {
|
||||
const PlacePickerScreen({super.key, this.places = defaultPlaces});
|
||||
|
||||
final List<String> places;
|
||||
final List<QuestPlace> places;
|
||||
|
||||
/// 어드민에 등록될 장소의 표본. **정책값이 아니다.**
|
||||
/// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.**
|
||||
static const defaultPlaces = [
|
||||
'홍대 팝업스토어',
|
||||
'HYBE 사옥',
|
||||
'SM 코엑스 아티움',
|
||||
'올림픽공원 체조경기장',
|
||||
'고척 스카이돔',
|
||||
QuestPlace(id: 'hongdae', name: '홍대 팝업스토어'),
|
||||
QuestPlace(id: 'hybe', name: 'HYBE 사옥'),
|
||||
QuestPlace(id: 'coex', name: 'SM 코엑스 아티움'),
|
||||
QuestPlace(id: 'kspo', name: '올림픽공원 체조경기장'),
|
||||
QuestPlace(id: 'gocheok', name: '고척 스카이돔'),
|
||||
];
|
||||
|
||||
/// 등록 장소 좌표가 하나도 없을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
|
||||
static const fallbackCenter = LatLng(37.5665, 126.9780);
|
||||
|
||||
@override
|
||||
State<PlacePickerScreen> createState() => _PlacePickerScreenState();
|
||||
}
|
||||
|
||||
class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
final _map = MapController();
|
||||
String _query = '';
|
||||
String? _selected;
|
||||
QuestPlace? _selected;
|
||||
|
||||
List<String> get _visible => widget.places
|
||||
.where((p) => _query.isEmpty || p.contains(_query))
|
||||
List<QuestPlace> get _visible => widget.places
|
||||
.where((p) => _query.isEmpty || p.name.contains(_query))
|
||||
.toList();
|
||||
|
||||
LatLng get _center {
|
||||
for (final p in widget.places) {
|
||||
if (p.lat != null && p.lng != null) return LatLng(p.lat!, p.lng!);
|
||||
}
|
||||
return PlacePickerScreen.fallbackCenter;
|
||||
}
|
||||
|
||||
void _choose(QuestPlace place) {
|
||||
setState(() => _selected = place);
|
||||
if (place.lat != null && place.lng != null) {
|
||||
_map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/// 지도를 누른 자리에 핀 — 주소는 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
|
||||
Future<void> _drop(LatLng point) async {
|
||||
final coords =
|
||||
'${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
|
||||
setState(() => _selected = QuestPlace(
|
||||
id: '', name: coords, lat: point.latitude, lng: point.longitude));
|
||||
var address = coords;
|
||||
try {
|
||||
final marks = await Geocoding().placemarkFromCoordinates(point.latitude, point.longitude);
|
||||
if (marks.isNotEmpty) {
|
||||
final m = marks.first;
|
||||
final parts = [m.administrativeArea, m.locality, m.subLocality, m.thoroughfare, m.subThoroughfare]
|
||||
.whereType<String>()
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (parts.isNotEmpty) address = parts.join(' ');
|
||||
}
|
||||
} catch (_) {
|
||||
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
||||
}
|
||||
if (!mounted || _selected?.lat != point.latitude) return;
|
||||
setState(() => _selected = QuestPlace(
|
||||
id: '', name: address, lat: point.latitude, lng: point.longitude));
|
||||
}
|
||||
|
||||
@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,
|
||||
Widget build(BuildContext context) {
|
||||
final pinned = _selected;
|
||||
return 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),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
pinned?.name ?? '',
|
||||
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(
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: FlutterMap(
|
||||
mapController: _map,
|
||||
options: MapOptions(
|
||||
initialCenter: _center,
|
||||
initialZoom: 15,
|
||||
onTap: (_, point) => _drop(point),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'kr.co.fanuzu.app',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
for (final p in widget.places)
|
||||
if (p.lat != null && p.lng != null)
|
||||
Marker(
|
||||
point: LatLng(p.lat!, p.lng!),
|
||||
child: GestureDetector(
|
||||
onTap: () => _choose(p),
|
||||
child: const Icon(Icons.place, color: AppColors.cosmicAccent),
|
||||
),
|
||||
),
|
||||
if (pinned != null && pinned.lat != null && pinned.lng != null)
|
||||
Marker(
|
||||
point: LatLng(pinned.lat!, pinned.lng!),
|
||||
alignment: Alignment.topCenter,
|
||||
child: const Icon(Icons.location_pin, size: 40, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
),
|
||||
// OSM 타일 이용 조건 — 출처 표기.
|
||||
const Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Text(
|
||||
context.l10n.commonNoResults,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
// 디폴트 미선택 · 단수 선택.
|
||||
: RadioGroup<String>(
|
||||
groupValue: _selected,
|
||||
onChanged: (v) => setState(() => _selected = v),
|
||||
child: ListView.builder(
|
||||
itemCount: _visible.length,
|
||||
itemBuilder: (context, i) {
|
||||
final place = _visible[i];
|
||||
return RadioListTile<String>(
|
||||
value: place,
|
||||
title: Text(place),
|
||||
secondary: const Icon(Icons.place_outlined),
|
||||
);
|
||||
},
|
||||
'© OpenStreetMap',
|
||||
style: TextStyle(fontSize: 10, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.placeSearch,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v.trim()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _visible.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.commonNoResults,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
// 디폴트 미선택 · 단수 선택.
|
||||
: RadioGroup<String>(
|
||||
groupValue: pinned?.id,
|
||||
onChanged: (v) => _choose(widget.places.firstWhere((p) => p.id == v)),
|
||||
child: ListView.builder(
|
||||
itemCount: _visible.length,
|
||||
itemBuilder: (context, i) {
|
||||
final place = _visible[i];
|
||||
return RadioListTile<String>(
|
||||
value: place.id,
|
||||
title: Text(place.name),
|
||||
secondary: const Icon(Icons.place_outlined),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: pinned == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(pinned),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
final _manualTitle = TextEditingController();
|
||||
final _reward = TextEditingController();
|
||||
final _participants = TextEditingController();
|
||||
// 만보기 목표 걸음(Figma `259:45798` 「목표 0 / 10,000」).
|
||||
final _goal = TextEditingController();
|
||||
|
||||
/// 이 화면 한 번의 생성 요청 키 — 응답 유실 뒤 다시 눌러도 서버가 한 건으로 묶는다.
|
||||
final _clientSeq = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
@@ -52,7 +54,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_title, _description, _manualTitle, _reward, _participants]) {
|
||||
for (final c in [_title, _description, _manualTitle, _reward, _participants, _goal]) {
|
||||
// 입력이 바뀔 때마다 미리보기와 완료 버튼 활성화가 함께 움직여야 한다.
|
||||
c.addListener(() => setState(() {}));
|
||||
}
|
||||
@@ -65,7 +67,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_title, _description, _url, _manualTitle, _reward, _participants]) {
|
||||
for (final c in [_title, _description, _url, _manualTitle, _reward, _participants, _goal]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
@@ -88,6 +90,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
bool get _ready {
|
||||
if (_activity == null || _title.text.trim().isEmpty) return false;
|
||||
if (_needs('place') && _place == null) return false;
|
||||
if (_needs('goal') && _intOf(_goal) <= 0) return false;
|
||||
if (_needs('link')) {
|
||||
final link = _link;
|
||||
if (link == null || !(link.ok || (link.failed && _manualTitle.text.trim().isNotEmpty))) {
|
||||
@@ -105,7 +108,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory,
|
||||
author: QuestAuthor.user,
|
||||
stage: QuestStage.mine,
|
||||
goal: 1,
|
||||
goal: _needs('goal') ? _intOf(_goal) : 1,
|
||||
progress: 0,
|
||||
rewardPop: _intOf(_reward),
|
||||
// 시안 — *참여 인원 : 0 / 0*. 분모가 모집 인원이다.
|
||||
@@ -284,6 +287,15 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
|
||||
if (_needs('goal')) ...[
|
||||
TextField(
|
||||
controller: _goal,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: _field(context.l10n.questGoalSteps),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
|
||||
if (_needs('place')) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -377,13 +389,11 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
if (!mounted) return;
|
||||
_places = places;
|
||||
}
|
||||
final name = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PlacePickerScreen(places: [for (final p in _places) p.name]),
|
||||
),
|
||||
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
|
||||
MaterialPageRoute(builder: (_) => PlacePickerScreen(places: _places)),
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
setState(() => _place = _places.firstWhere((p) => p.name == name));
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _place = picked);
|
||||
}
|
||||
|
||||
/// 공개일을 1일째로 최대 [QuestCatalog.maxDays] 일째까지. 남은 1시간 미만 판정은 서버가 한다.
|
||||
@@ -433,7 +443,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
endDate: _endDate,
|
||||
url: _needs('link') ? _link?.url : null,
|
||||
manualTitle: _needs('link') && (_link?.failed ?? false) ? _manualTitle.text.trim() : null,
|
||||
placeId: _needs('place') ? _place?.id : null,
|
||||
placeId: _needs('place') && !(_place?.isPin ?? true) ? _place?.id : null,
|
||||
pin: _needs('place') && (_place?.isPin ?? false) ? _place : null,
|
||||
goal: _needs('goal') ? _intOf(_goal) : null,
|
||||
),
|
||||
);
|
||||
} on BackendException catch (error) {
|
||||
|
||||
@@ -232,12 +232,18 @@ class QuestCatalog {
|
||||
: (pool * feeRate).ceil();
|
||||
}
|
||||
|
||||
/// 위치 Quest 의 등록 장소 — `places/{placeId}`.
|
||||
/// 위치 Quest 의 장소 — 등록 장소(`places/{placeId}`) 또는 지도에 꽂은 핀(`id` 빈 값).
|
||||
class QuestPlace {
|
||||
const QuestPlace({required this.id, required this.name});
|
||||
const QuestPlace({required this.id, required this.name, this.lat, this.lng});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 등록 장소 이름 또는 핀 주소.
|
||||
final String name;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
|
||||
bool get isPin => id.isEmpty;
|
||||
}
|
||||
|
||||
/// 링크 미리보기 — 서버 `fn-link-preview`(C-05). [status] 는 `ok` · `preview_failed` · `invalid`.
|
||||
@@ -272,10 +278,20 @@ class QuestSubmission {
|
||||
this.rejectReason,
|
||||
this.resubmitAllowed = false,
|
||||
this.summary = '',
|
||||
this.txId,
|
||||
this.paidAt,
|
||||
this.adClaimed = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String status;
|
||||
|
||||
/// 승인 지급의 기본 보상 원장 txId · 지급 시각 — 광고 추가 보상을 붙이는 기준(Q-UQ-03 A).
|
||||
final String? txId;
|
||||
final DateTime? paidAt;
|
||||
|
||||
/// 이 지급에 광고 추가 보상을 이미 받았는가(`users/{uid}/adBonuses/{txId}`).
|
||||
final bool adClaimed;
|
||||
final DateTime? submittedAt;
|
||||
final String? rejectReason;
|
||||
final bool resubmitAllowed;
|
||||
@@ -414,10 +430,19 @@ class QuestDetail {
|
||||
this.repeatMax = 1,
|
||||
this.submission,
|
||||
this.reviews = const [],
|
||||
this.publishState = '',
|
||||
this.moderationReason,
|
||||
});
|
||||
|
||||
final QuestCard card;
|
||||
|
||||
/// 사용자 생성 Quest 의 공개 상태(`quests.status`) — `review`(검수 대기) · `published` ·
|
||||
/// `rejected`(운영 반려) · `cancelled` · `ended`. 운영 Quest 는 빈 값.
|
||||
final String publishState;
|
||||
|
||||
/// 운영 반려 사유(`quests.moderation.reason`).
|
||||
final String? moderationReason;
|
||||
|
||||
/// 대표 이미지. 자산이 아직 없어 라벨로 둔다.
|
||||
final String imageLabel;
|
||||
|
||||
@@ -475,8 +500,16 @@ class QuestDraft {
|
||||
this.url,
|
||||
this.manualTitle,
|
||||
this.placeId,
|
||||
this.pin,
|
||||
this.goal,
|
||||
});
|
||||
|
||||
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
|
||||
final QuestPlace? pin;
|
||||
|
||||
/// 만보기 목표 걸음 수(`inputSchema` 의 `goal`).
|
||||
final int? goal;
|
||||
|
||||
/// 같은 화면의 재시도를 한 건으로 묶는 멱등 키.
|
||||
final String clientSeq;
|
||||
final String activityKey;
|
||||
@@ -529,6 +562,10 @@ abstract interface class QuestRepository {
|
||||
required String description,
|
||||
});
|
||||
|
||||
/// 생성자 취소(B-30) — 검수 중이면 예치 · 수수료 전액 반환, 공개 후면 수수료 비환불 ·
|
||||
/// 미배정 Pool 즉시 반환.
|
||||
Future<void> cancel(String questId);
|
||||
|
||||
/// 상태 전이. 서버가 확정한 상태를 돌려준다.
|
||||
Future<QuestStage> advance(String questId, QuestStage next);
|
||||
|
||||
@@ -565,12 +602,14 @@ abstract interface class QuestRepository {
|
||||
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
|
||||
///
|
||||
/// 사용자가 추가 보상 모달에서 **직접 고른 뒤에만** 부른다(별첨 1 ⑥ No-Force ·
|
||||
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」).
|
||||
Future<RewardedAdHandle> loadRewardedAd();
|
||||
/// 정책 원장 결정 8 「Reward Ad 는 명시적 옵트인」). [base] 는 광고를 붙일 기본 보상이다 —
|
||||
/// 서버측 시청 검증(AdMob SSV)에 그 txId 를 싣는다.
|
||||
Future<RewardedAdHandle> loadRewardedAd(QuestReward base);
|
||||
|
||||
/// 끝까지 본 광고의 추가 보상을 서버에 청구하고 지급액을 돌려준다. [base] 는 방금 받은
|
||||
/// 기본 보상이다 — 서버가 그 원장 행으로 증분을 계산한다(B-03 · C-02).
|
||||
/// 일일 한도를 넘으면 E-AD-02, 같은 노출의 재전송은 첫 지급 결과다.
|
||||
/// 지급 근거는 Google 이 서버로 보낸 SSV 기록이다(없으면 E-AD-03 — 증분 미지급 · 횟수 미차감).
|
||||
/// 일일 한도를 넘으면 E-AD-02, 같은 기본 보상의 재전송은 첫 지급 결과다.
|
||||
Future<int> claimAdReward(String impressionId, QuestReward base);
|
||||
}
|
||||
|
||||
@@ -730,10 +769,7 @@ class SampleQuestRepository implements QuestRepository {
|
||||
Future<QuestCatalog> catalog() async => _catalog;
|
||||
|
||||
@override
|
||||
Future<List<QuestPlace>> places() async => [
|
||||
for (final name in PlacePickerScreen.defaultPlaces)
|
||||
QuestPlace(id: name, name: name),
|
||||
];
|
||||
Future<List<QuestPlace>> places() async => PlacePickerScreen.defaultPlaces;
|
||||
|
||||
/// 표본은 네트워크를 쓰지 않는다 — 형식만 보고 조회 실패(기본 카드)로 돌려준다.
|
||||
@override
|
||||
@@ -766,6 +802,9 @@ class SampleQuestRepository implements QuestRepository {
|
||||
/// 이것이 없으면 보상을 받은 뒤 화면을 다시 읽을 때 또 「보상받기」로 보인다.
|
||||
static final _claimed = <String>{};
|
||||
|
||||
@override
|
||||
Future<void> cancel(String questId) async {}
|
||||
|
||||
@override
|
||||
Future<QuestStage> advance(String questId, QuestStage next) async => next;
|
||||
|
||||
@@ -802,7 +841,7 @@ class SampleQuestRepository implements QuestRepository {
|
||||
|
||||
/// 표본은 광고 SDK 를 부르지 않는다 — 잠깐 기다렸다가 끝까지 본 것으로 둔다.
|
||||
@override
|
||||
Future<RewardedAdHandle> loadRewardedAd() async {
|
||||
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) async {
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
return const _SampleAd();
|
||||
}
|
||||
|
||||
@@ -120,8 +120,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
onSelected: (value) =>
|
||||
value == 'edit' ? _edit(detail) : _report(card.title),
|
||||
onSelected: (value) => switch (value) {
|
||||
'edit' => _edit(detail),
|
||||
'cancel' => _cancel(detail),
|
||||
_ => _report(card.title),
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
|
||||
if (stage == QuestStage.mine && ugc)
|
||||
@@ -129,6 +132,15 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
value: 'edit',
|
||||
child: Text(context.l10n.questEdit),
|
||||
),
|
||||
// 생성자 취소(B-30) — 검수 중 · 공개 중에만.
|
||||
if (stage == QuestStage.mine &&
|
||||
ugc &&
|
||||
(detail.publishState == 'review' ||
|
||||
detail.publishState == 'published'))
|
||||
PopupMenuItem(
|
||||
value: 'cancel',
|
||||
child: Text(context.l10n.questCancel),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'report',
|
||||
child: Text(context.l10n.reportTitle),
|
||||
@@ -309,7 +321,38 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(blocked, style: _strong),
|
||||
),
|
||||
// 공개 전 검수(1주차 4항 · Q8) — 생성자에게 검수 대기 · 반려 사유를 보인다.
|
||||
if (stage == QuestStage.mine && detail.publishState == 'review')
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(context.l10n.questUnderReview, style: _strong),
|
||||
),
|
||||
if (stage == QuestStage.mine && detail.publishState == 'rejected')
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(
|
||||
context.l10n.questModerationRejected(
|
||||
detail.moderationReason ?? '',
|
||||
),
|
||||
style: _strong,
|
||||
),
|
||||
),
|
||||
if (submission != null) ..._submissionStatus(submission),
|
||||
// 검토형 Quest 의 광고 추가 보상은 승인 · 지급된 날에 붙인다(10/3 통합회신 · Q-UQ-03 A).
|
||||
if (submission != null &&
|
||||
submission.status == 'paid' &&
|
||||
submission.txId != null &&
|
||||
!submission.adClaimed &&
|
||||
submission.paidAt != null &&
|
||||
_sameKstDay(submission.paidAt!, ServerClock.now()) &&
|
||||
card.adBonusPop != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : () => _approvedAdBonus(submission),
|
||||
child: Text(context.l10n.questAdBonus('${card.adBonusPop}')),
|
||||
),
|
||||
),
|
||||
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
|
||||
],
|
||||
),
|
||||
@@ -561,6 +604,43 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
}
|
||||
|
||||
/// 제목 · 설명만 고친다(C-07). 잠금 필드는 입력 자체가 없다.
|
||||
static bool _sameKstDay(DateTime a, DateTime b) {
|
||||
String day(DateTime t) => t
|
||||
.toUtc()
|
||||
.add(const Duration(hours: 9))
|
||||
.toIso8601String()
|
||||
.substring(0, 10);
|
||||
return day(a) == day(b);
|
||||
}
|
||||
|
||||
/// 승인 지급분에 광고 추가 보상 — 기본 보상 txId 를 SSV 에 실어 광고를 보고 서버에 청구한다.
|
||||
Future<void> _approvedAdBonus(QuestSubmission submission) async {
|
||||
final base = QuestReward(
|
||||
rewardedPop: 0,
|
||||
balanceAfter: 0,
|
||||
txId: submission.txId,
|
||||
);
|
||||
final impression = await _watchAd(base);
|
||||
if (impression == null || !mounted) return;
|
||||
await _run(() async {
|
||||
final pop = await widget.repository.claimAdReward(impression, base);
|
||||
if (mounted) _toast(context.l10n.questAdBonus(formatPop(pop)));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancel(QuestDetail detail) async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questCancel,
|
||||
body: detail.publishState == 'review'
|
||||
? context.l10n.questCancelInReview
|
||||
: context.l10n.questCancelPublished,
|
||||
confirmLabel: context.l10n.questCancel,
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
await _run(() => widget.repository.cancel(detail.card.id));
|
||||
}
|
||||
|
||||
Future<void> _edit(QuestDetail detail) async {
|
||||
final title = TextEditingController(text: detail.card.title);
|
||||
final description = TextEditingController(text: detail.description);
|
||||
@@ -774,7 +854,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
|
||||
if (error is BackendException &&
|
||||
error.code == 'E-QST-01' &&
|
||||
detail.card.type == QuestType.timeAttack) {
|
||||
detail.card.endsAt != null) {
|
||||
await _load();
|
||||
}
|
||||
return;
|
||||
@@ -786,7 +866,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
_stage = QuestStage.rewarded;
|
||||
});
|
||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다.
|
||||
if (detail.card.type == QuestType.streaming) {
|
||||
if (detail.card.type == QuestType.streaming && detail.card.endsAt == null) {
|
||||
_toast(context.l10n.questStreamJoined);
|
||||
final url = Uri.tryParse(detail.link?.url ?? '');
|
||||
if (url != null && url.hasScheme) {
|
||||
@@ -811,7 +891,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
int? adPop;
|
||||
if (bonus) {
|
||||
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
|
||||
final impression = await _watchAd();
|
||||
final impression = await _watchAd(reward);
|
||||
if (!mounted) return;
|
||||
if (impression != null) {
|
||||
final claim = widget.repository.claimAdReward(impression, reward);
|
||||
@@ -848,10 +928,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
|
||||
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
|
||||
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
|
||||
Future<String?> _watchAd() async {
|
||||
Future<String?> _watchAd(QuestReward reward) async {
|
||||
RewardedAdHandle? ad;
|
||||
BackendException? failure;
|
||||
final loading = widget.repository.loadRewardedAd().then<void>(
|
||||
final loading = widget.repository.loadRewardedAd(reward).then<void>(
|
||||
(handle) => ad = handle,
|
||||
onError: (Object error) => failure = error is BackendException
|
||||
? error
|
||||
|
||||
@@ -355,7 +355,18 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
builder: (_) => QuestCreateScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (id != null && mounted) await _load();
|
||||
if (id != null && mounted) {
|
||||
// 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8).
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.questSubmittedForReview),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
final snap = await query.get();
|
||||
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
|
||||
|
||||
// 타임어택 창은 서버 시각이다. 타임어택이 있을 때만 서버 시계를 잰다(세션당 1회).
|
||||
if (snap.docs.any((d) => d.data()['type'] == 'timeattack')) {
|
||||
// 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회).
|
||||
if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) {
|
||||
await ServerClock.ensure(_backend);
|
||||
}
|
||||
final now = ServerClock.now();
|
||||
@@ -133,6 +133,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
// 사용자 생성 Quest — 공개 전(Draft) · 취소는 감추고, 마감분은 생성자의 「내 퀘스트」에만 남긴다.
|
||||
final status = quest.strOr('status');
|
||||
if (status == 'draft' || status == 'cancelled') return false;
|
||||
final mineFirst = quest.strOr('authorUid') == uid;
|
||||
// 공개 전 검수 대기 · 운영 반려는 생성자에게만 보인다(승인 후 공개 — 1주차 4항 · Q8).
|
||||
if (!mineFirst && (status == 'review' || status == 'rejected')) return false;
|
||||
final mine = quest.strOr('authorUid') == uid;
|
||||
final endsAt = quest['endsAt'];
|
||||
if (!mine && (status == 'ended' || (endsAt is Timestamp && !now.isBefore(endsAt.toDate())))) {
|
||||
@@ -186,7 +189,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
|
||||
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
|
||||
progress: type.gathers ? (done ? goal : 0) : count,
|
||||
rewardPop: type.rewardFrom(quest, policy),
|
||||
rewardPop: openTimeAttack(quest)?.rewardPop ?? type.rewardFrom(quest, policy),
|
||||
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
|
||||
participants: quest.intOr('participantCount'),
|
||||
// 사용자 생성 Quest 의 모집 인원(`capacity`). 운영 Quest 에는 없다.
|
||||
@@ -194,9 +197,8 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
// 광고 안내값 — 서버 `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,
|
||||
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
|
||||
endsAt: openTimeAttack(quest)?.endsAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,6 +245,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
repeatMax: repeat?['allowed'] == true ? repeat!.intOr('maxCount', 1) : 1,
|
||||
submission: submission,
|
||||
reviews: quest.strOr('authorUid') == uid ? await _reviews(questId, uid) : const [],
|
||||
publishState: quest.strOr('status'),
|
||||
moderationReason: quest['moderation'] is Map
|
||||
? (quest['moderation'] as Map)['reason'] as String?
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +266,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
? Map<String, dynamic>.from(evidence.last as Map)
|
||||
: const <String, dynamic>{};
|
||||
final at = m['resubmittedAt'] ?? m['submittedAt'];
|
||||
final paidAt = m['paidAt'];
|
||||
return QuestSubmission(
|
||||
txId: m.strOr('txId').isEmpty ? null : m.strOr('txId'),
|
||||
paidAt: paidAt is Timestamp ? paidAt.toDate() : null,
|
||||
id: id,
|
||||
status: m.strOr('status'),
|
||||
submittedAt: at is Timestamp ? at.toDate() : null,
|
||||
@@ -270,13 +279,34 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/// 내 제출 건. 사용자 생성 Quest 만 있다(문서 ID `{questId}_{uid}`).
|
||||
/// 내 제출 건(문서 ID `{questId}_{uid}`). 사용자 생성 Quest 와 운영 Vote · SNS · Community Quest 에 있다.
|
||||
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
|
||||
if (quest.strOr('authorUid').isEmpty) return null;
|
||||
final type = QuestType.parse(quest.strOr('type'));
|
||||
if (quest.strOr('authorUid').isEmpty && !type.reviewed && type != QuestType.community) {
|
||||
return null;
|
||||
}
|
||||
final id = '${questId}_${_backend.requireUid}';
|
||||
try {
|
||||
final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
|
||||
return data == null ? null : _submissionOf(id, data);
|
||||
if (data == null) return null;
|
||||
final sub = _submissionOf(id, data);
|
||||
final txId = sub.txId;
|
||||
if (txId == null) return sub;
|
||||
final claimed = (await _backend.db
|
||||
.doc('users/${_backend.requireUid}/adBonuses/$txId')
|
||||
.get())
|
||||
.exists;
|
||||
return QuestSubmission(
|
||||
id: sub.id,
|
||||
status: sub.status,
|
||||
submittedAt: sub.submittedAt,
|
||||
rejectReason: sub.rejectReason,
|
||||
resubmitAllowed: sub.resubmitAllowed,
|
||||
summary: sub.summary,
|
||||
txId: txId,
|
||||
paidAt: sub.paidAt,
|
||||
adClaimed: claimed,
|
||||
);
|
||||
} on Exception {
|
||||
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
|
||||
return null;
|
||||
@@ -365,7 +395,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
Future<List<QuestPlace>> places() async {
|
||||
final snap = await _backend.db.collection('places').get();
|
||||
return [
|
||||
for (final d in snap.docs) QuestPlace(id: d.id, name: d.data().strOr('name', d.id)),
|
||||
for (final d in snap.docs)
|
||||
QuestPlace(
|
||||
id: d.id,
|
||||
name: d.data().strOr('name', d.id),
|
||||
lat: (d.data()['geo'] as GeoPoint?)?.latitude,
|
||||
lng: (d.data()['geo'] as GeoPoint?)?.longitude,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -394,6 +430,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
'resubmitAllowed': resubmitAllowed,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> cancel(String questId) =>
|
||||
_backend.call('fnQuestCancel', {'questId': questId});
|
||||
|
||||
@override
|
||||
Future<void> edit(String questId, {required String title, required String description}) =>
|
||||
_backend.call('fnQuestEdit', {
|
||||
@@ -433,6 +473,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
if (draft.url != null) 'url': draft.url,
|
||||
if (draft.manualTitle != null) 'manualTitle': draft.manualTitle,
|
||||
if (draft.placeId != null) 'placeId': draft.placeId,
|
||||
if (draft.pin case final pin?)
|
||||
'pin': {'lat': pin.lat, 'lng': pin.lng, 'address': pin.name},
|
||||
if (draft.goal != null) 'goal': draft.goal,
|
||||
});
|
||||
return result.strOr('questId');
|
||||
}
|
||||
@@ -456,10 +499,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
@override
|
||||
Future<QuestReward> verify(QuestCard card) async {
|
||||
final reason = card.type.blockedReason;
|
||||
// 열린 TIME ATTACK 회차 — 원 Quest 유형과 무관하게 회차 참여로 보낸다(10/3 통합회신 Q-TA-01).
|
||||
final reason = card.endsAt != null ? null : card.type.blockedReason;
|
||||
if (reason != null) throw BackendException('E-QST-01', reason);
|
||||
|
||||
final result = switch (card.type) {
|
||||
final result = card.endsAt != null
|
||||
? await _backend.call('fnQuestTimeAttack', {'questId': card.id})
|
||||
: switch (card.type) {
|
||||
// **퀘스트 문서 ID 를 함께 보낸다.** 서버가 진행 문서를 이 ID 로 쓰고 일일 한도를
|
||||
// `quests/{questId}.dailyLimit` 에서 읽는다. 보내지 않으면 서버는 유형 이름에
|
||||
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
|
||||
@@ -468,8 +514,6 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
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}),
|
||||
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
|
||||
QuestType.streaming =>
|
||||
await _backend.call('fnQuestStreamParticipate', {'questId': card.id}),
|
||||
@@ -486,7 +530,16 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardedAdHandle> loadRewardedAd() => loadGoogleRewardedAd();
|
||||
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) {
|
||||
final baseTxId = base.txId;
|
||||
if (baseTxId == null) {
|
||||
throw BackendException('E-QST-01', appL10n.questNoBaseReward);
|
||||
}
|
||||
return loadGoogleRewardedAd(
|
||||
userId: _backend.requireUid,
|
||||
customData: baseTxId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> claimAdReward(String impressionId, QuestReward base) async {
|
||||
@@ -494,12 +547,22 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
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');
|
||||
// 시청 확인은 Google 이 서버로 보내는 SSV 콜백이 한다. 콜백은 광고가 닫힌 뒤 몇 초 안에
|
||||
// 도착하므로, 아직이면(E-AD-03) 잠시 기다렸다 다시 묻는다. 끝내 없으면 증분 없이 끝난다(B-03).
|
||||
for (var attempt = 0; ; attempt++) {
|
||||
try {
|
||||
final result = await _backend.call('fnAdReward', {'baseTxId': baseTxId});
|
||||
return result.intOr('rewarded');
|
||||
} on BackendException catch (error) {
|
||||
if (error.code != 'E-AD-03' || attempt >= 5) {
|
||||
if (error.code == 'E-AD-03') {
|
||||
throw BackendException('E-AD-03', appL10n.adNotVerified);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifySteps(QuestCard card) async {
|
||||
@@ -655,6 +718,12 @@ List<int> _stepTiers(Map<String, dynamic> policy) {
|
||||
DateTime? now,
|
||||
]) {
|
||||
final p = progress ?? const <String, dynamic>{};
|
||||
// 열린 TIME ATTACK 회차는 회차 단위 1인 1회다 — 그날 원 Quest 를 했어도 회차는 따로 받는다.
|
||||
final round = openTimeAttack(quest);
|
||||
if (round != null) {
|
||||
final joined = p.strOr('timeAttackRoundId') == round.roundId;
|
||||
return (count: joined ? 1 : 0, done: joined);
|
||||
}
|
||||
final sameDay = type == QuestType.timeAttack ||
|
||||
p.strOr('dayKey') == _dayKey(now ?? DateTime.now());
|
||||
final count = sameDay ? p.intOr('count') : 0;
|
||||
@@ -663,3 +732,24 @@ List<int> _stepTiers(Map<String, dynamic> policy) {
|
||||
: count >= quest.intOr('dailyLimit', 1);
|
||||
return (count: count, done: done);
|
||||
}
|
||||
|
||||
/// 열린 TIME ATTACK 회차 — 원 Quest 문서의 `timeAttack`(10/3 통합회신: 별도 유형이 아니라
|
||||
/// 기존 Quest 에 붙는 제한시간 모드). 창(`activeFrom` ~ `activeTo`, 서버 시각) 밖이면 null.
|
||||
/// 화면 표시용이다 — 참여 수락은 서버(`fn-quest-timeattack`)가 서버 시각으로 다시 판정한다.
|
||||
({String roundId, DateTime endsAt, int rewardPop})? openTimeAttack(
|
||||
Map<String, dynamic> quest,
|
||||
) {
|
||||
final raw = quest['timeAttack'];
|
||||
if (raw is! Map) return null;
|
||||
final round = Map<String, dynamic>.from(raw);
|
||||
final from = round['activeFrom'];
|
||||
final to = round['activeTo'];
|
||||
if (from is! Timestamp || to is! Timestamp) return null;
|
||||
final now = ServerClock.now();
|
||||
if (now.isBefore(from.toDate()) || !now.isBefore(to.toDate())) return null;
|
||||
return (
|
||||
roundId: round.strOr('roundId'),
|
||||
endsAt: to.toDate(),
|
||||
rewardPop: round.intOr('rewardPop'),
|
||||
);
|
||||
}
|
||||
|
||||
+10
-1
@@ -622,6 +622,7 @@
|
||||
"questStopBody": "Your progress will be reset to the beginning.",
|
||||
"questStop": "Stop",
|
||||
"adNotFinished": "You didn't finish the ad, so there's no bonus. You still get the basic reward.",
|
||||
"adNotVerified": "We couldn't confirm the ad view, so there's no bonus. You still get the basic reward.",
|
||||
"openSettings": "Open settings",
|
||||
"permStepsDenied": "Step permission is off. Allow \"Physical activity\" in Settings.",
|
||||
"permStepsNeeded": "Physical activity permission is needed to count steps.",
|
||||
@@ -817,5 +818,13 @@
|
||||
"shakeJudged": "Judged {n} / {total}",
|
||||
"shakePlayHint": "Play the video and shake your phone to the beat",
|
||||
"shakeVideoError": "Can't play this video",
|
||||
"shakeShareResult": "Share result"
|
||||
"shakeShareResult": "Share result",
|
||||
"questCancel": "Cancel quest",
|
||||
"questCancelInReview": "Cancelling while under review returns the deposited reward POP and the fee in full.",
|
||||
"questCancelPublished": "Cancelling a published quest closes it now and returns the unused reward POP. The fee is not refunded.",
|
||||
"questUnderReview": "Under review. It will be published once approved.",
|
||||
"questModerationRejected": "Rejected in review: {reason}. Your deposit and fee were returned.",
|
||||
"questSubmittedForReview": "Submitted. It will be published after review.",
|
||||
"popReasonQuestFeeRefund": "Quest fee refund",
|
||||
"questGoalSteps": "Step goal"
|
||||
}
|
||||
|
||||
+10
-1
@@ -622,6 +622,7 @@
|
||||
"questStopBody": "Tu progreso se reiniciará desde el principio.",
|
||||
"questStop": "Detener",
|
||||
"adNotFinished": "No terminaste el anuncio, así que no hay extra. Recibes la recompensa básica.",
|
||||
"adNotVerified": "No pudimos confirmar el anuncio, así que no hay extra. Recibes la recompensa básica.",
|
||||
"openSettings": "Abrir ajustes",
|
||||
"permStepsDenied": "El permiso de pasos está desactivado. Permite \"Actividad física\" en Ajustes.",
|
||||
"permStepsNeeded": "Se necesita el permiso de actividad física para contar pasos.",
|
||||
@@ -817,5 +818,13 @@
|
||||
"shakeJudged": "Evaluados {n} / {total}",
|
||||
"shakePlayHint": "Reproduce el video y agita el teléfono al ritmo",
|
||||
"shakeVideoError": "No se puede reproducir este video",
|
||||
"shakeShareResult": "Compartir resultado"
|
||||
"shakeShareResult": "Compartir resultado",
|
||||
"questCancel": "Cancelar misión",
|
||||
"questCancelInReview": "Si cancelas durante la revisión, te devolvemos todo el POP depositado y la comisión.",
|
||||
"questCancelPublished": "Si cancelas una misión publicada, se cierra al momento y te devolvemos el POP no usado. La comisión no se reembolsa.",
|
||||
"questUnderReview": "En revisión. Se publicará cuando se apruebe.",
|
||||
"questModerationRejected": "Rechazada en revisión: {reason}. Te devolvimos el depósito y la comisión.",
|
||||
"questSubmittedForReview": "Enviada. Se publicará tras la revisión.",
|
||||
"popReasonQuestFeeRefund": "Reembolso de comisión de misión",
|
||||
"questGoalSteps": "Meta de pasos"
|
||||
}
|
||||
|
||||
+10
-1
@@ -622,6 +622,7 @@
|
||||
"questStopBody": "これまでの進行状況がリセットされ、最初の状態に戻ります。",
|
||||
"questStop": "中断",
|
||||
"adNotFinished": "広告を最後まで見なかったため追加報酬はありません。基本報酬はそのまま受け取れます。",
|
||||
"adNotVerified": "広告の視聴を確認できなかったため追加報酬はありません。基本報酬はそのまま受け取れます。",
|
||||
"openSettings": "設定を開く",
|
||||
"permStepsDenied": "歩数の権限がオフです。設定で「身体活動」を許可してください。",
|
||||
"permStepsNeeded": "歩数を数えるには身体活動の権限が必要です。",
|
||||
@@ -817,5 +818,13 @@
|
||||
"shakeJudged": "判定 {n} / {total}",
|
||||
"shakePlayHint": "動画を再生してビートに合わせてスマホを振ってください",
|
||||
"shakeVideoError": "この動画を再生できません",
|
||||
"shakeShareResult": "結果をシェア"
|
||||
"shakeShareResult": "結果をシェア",
|
||||
"questCancel": "クエストを取り消す",
|
||||
"questCancelInReview": "審査中に取り消すと、預けた報酬POPと手数料はすべて返還されます。",
|
||||
"questCancelPublished": "公開中のクエストを取り消すと参加はすぐ締め切られ、残りの報酬POPは返還されます。手数料は返還されません。",
|
||||
"questUnderReview": "運営の審査中です。承認されると公開されます。",
|
||||
"questModerationRejected": "審査で差し戻されました:{reason}。預けたPOPと手数料は返還済みです。",
|
||||
"questSubmittedForReview": "公開を申請しました。審査後に公開されます。",
|
||||
"popReasonQuestFeeRefund": "クエスト手数料の返還",
|
||||
"questGoalSteps": "目標歩数"
|
||||
}
|
||||
|
||||
+11
-1
@@ -1230,6 +1230,7 @@
|
||||
"questStopBody": "지금까지의 진행이 초기화되고 처음 상태로 돌아갑니다.",
|
||||
"questStop": "중단",
|
||||
"adNotFinished": "광고를 끝까지 보지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.",
|
||||
"adNotVerified": "광고 시청이 확인되지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.",
|
||||
"openSettings": "설정 열기",
|
||||
"permStepsDenied": "걸음 수 권한이 꺼져 있습니다. 설정에서 「신체 활동」을 허용해 주세요.",
|
||||
"permStepsNeeded": "걸음 수를 세려면 신체 활동 권한이 필요합니다.",
|
||||
@@ -1750,5 +1751,14 @@
|
||||
},
|
||||
"shakePlayHint": "영상을 재생하고 박자에 맞춰 폰을 흔들어 주세요",
|
||||
"shakeVideoError": "영상을 재생할 수 없어요",
|
||||
"shakeShareResult": "결과 공유"
|
||||
"shakeShareResult": "결과 공유",
|
||||
"questCancel": "퀘스트 취소",
|
||||
"questCancelInReview": "검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.",
|
||||
"questCancelPublished": "공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.",
|
||||
"questUnderReview": "운영 검수 중이에요. 승인되면 공개됩니다.",
|
||||
"questModerationRejected": "운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.",
|
||||
"@questModerationRejected": {"placeholders": {"reason": {"type": "String"}}},
|
||||
"questSubmittedForReview": "공개 요청했어요. 운영 검수 후 공개됩니다.",
|
||||
"popReasonQuestFeeRefund": "퀘스트 수수료 반환",
|
||||
"questGoalSteps": "목표 걸음 수"
|
||||
}
|
||||
|
||||
@@ -3836,6 +3836,12 @@ abstract class AppLocalizations {
|
||||
/// **'광고를 끝까지 보지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.'**
|
||||
String get adNotFinished;
|
||||
|
||||
/// No description provided for @adNotVerified.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'광고 시청이 확인되지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.'**
|
||||
String get adNotVerified;
|
||||
|
||||
/// No description provided for @openSettings.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
@@ -5023,6 +5029,54 @@ abstract class AppLocalizations {
|
||||
/// In ko, this message translates to:
|
||||
/// **'결과 공유'**
|
||||
String get shakeShareResult;
|
||||
|
||||
/// No description provided for @questCancel.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'퀘스트 취소'**
|
||||
String get questCancel;
|
||||
|
||||
/// No description provided for @questCancelInReview.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.'**
|
||||
String get questCancelInReview;
|
||||
|
||||
/// No description provided for @questCancelPublished.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.'**
|
||||
String get questCancelPublished;
|
||||
|
||||
/// No description provided for @questUnderReview.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'운영 검수 중이에요. 승인되면 공개됩니다.'**
|
||||
String get questUnderReview;
|
||||
|
||||
/// No description provided for @questModerationRejected.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.'**
|
||||
String questModerationRejected(String reason);
|
||||
|
||||
/// No description provided for @questSubmittedForReview.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'공개 요청했어요. 운영 검수 후 공개됩니다.'**
|
||||
String get questSubmittedForReview;
|
||||
|
||||
/// No description provided for @popReasonQuestFeeRefund.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'퀘스트 수수료 반환'**
|
||||
String get popReasonQuestFeeRefund;
|
||||
|
||||
/// No description provided for @questGoalSteps.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'목표 걸음 수'**
|
||||
String get questGoalSteps;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2155,6 +2155,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get adNotFinished =>
|
||||
'You didn\'t finish the ad, so there\'s no bonus. You still get the basic reward.';
|
||||
|
||||
@override
|
||||
String get adNotVerified =>
|
||||
'We couldn\'t confirm the ad view, so there\'s no bonus. You still get the basic reward.';
|
||||
|
||||
@override
|
||||
String get openSettings => 'Open settings';
|
||||
|
||||
@@ -2861,4 +2865,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get shakeShareResult => 'Share result';
|
||||
|
||||
@override
|
||||
String get questCancel => 'Cancel quest';
|
||||
|
||||
@override
|
||||
String get questCancelInReview =>
|
||||
'Cancelling while under review returns the deposited reward POP and the fee in full.';
|
||||
|
||||
@override
|
||||
String get questCancelPublished =>
|
||||
'Cancelling a published quest closes it now and returns the unused reward POP. The fee is not refunded.';
|
||||
|
||||
@override
|
||||
String get questUnderReview =>
|
||||
'Under review. It will be published once approved.';
|
||||
|
||||
@override
|
||||
String questModerationRejected(String reason) {
|
||||
return 'Rejected in review: $reason. Your deposit and fee were returned.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questSubmittedForReview =>
|
||||
'Submitted. It will be published after review.';
|
||||
|
||||
@override
|
||||
String get popReasonQuestFeeRefund => 'Quest fee refund';
|
||||
|
||||
@override
|
||||
String get questGoalSteps => 'Step goal';
|
||||
}
|
||||
|
||||
@@ -2169,6 +2169,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get adNotFinished =>
|
||||
'No terminaste el anuncio, así que no hay extra. Recibes la recompensa básica.';
|
||||
|
||||
@override
|
||||
String get adNotVerified =>
|
||||
'No pudimos confirmar el anuncio, así que no hay extra. Recibes la recompensa básica.';
|
||||
|
||||
@override
|
||||
String get openSettings => 'Abrir ajustes';
|
||||
|
||||
@@ -2877,4 +2881,33 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get shakeShareResult => 'Compartir resultado';
|
||||
|
||||
@override
|
||||
String get questCancel => 'Cancelar misión';
|
||||
|
||||
@override
|
||||
String get questCancelInReview =>
|
||||
'Si cancelas durante la revisión, te devolvemos todo el POP depositado y la comisión.';
|
||||
|
||||
@override
|
||||
String get questCancelPublished =>
|
||||
'Si cancelas una misión publicada, se cierra al momento y te devolvemos el POP no usado. La comisión no se reembolsa.';
|
||||
|
||||
@override
|
||||
String get questUnderReview => 'En revisión. Se publicará cuando se apruebe.';
|
||||
|
||||
@override
|
||||
String questModerationRejected(String reason) {
|
||||
return 'Rechazada en revisión: $reason. Te devolvimos el depósito y la comisión.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questSubmittedForReview =>
|
||||
'Enviada. Se publicará tras la revisión.';
|
||||
|
||||
@override
|
||||
String get popReasonQuestFeeRefund => 'Reembolso de comisión de misión';
|
||||
|
||||
@override
|
||||
String get questGoalSteps => 'Meta de pasos';
|
||||
}
|
||||
|
||||
@@ -2090,6 +2090,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get adNotFinished => '広告を最後まで見なかったため追加報酬はありません。基本報酬はそのまま受け取れます。';
|
||||
|
||||
@override
|
||||
String get adNotVerified => '広告の視聴を確認できなかったため追加報酬はありません。基本報酬はそのまま受け取れます。';
|
||||
|
||||
@override
|
||||
String get openSettings => '設定を開く';
|
||||
|
||||
@@ -2769,4 +2772,31 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get shakeShareResult => '結果をシェア';
|
||||
|
||||
@override
|
||||
String get questCancel => 'クエストを取り消す';
|
||||
|
||||
@override
|
||||
String get questCancelInReview => '審査中に取り消すと、預けた報酬POPと手数料はすべて返還されます。';
|
||||
|
||||
@override
|
||||
String get questCancelPublished =>
|
||||
'公開中のクエストを取り消すと参加はすぐ締め切られ、残りの報酬POPは返還されます。手数料は返還されません。';
|
||||
|
||||
@override
|
||||
String get questUnderReview => '運営の審査中です。承認されると公開されます。';
|
||||
|
||||
@override
|
||||
String questModerationRejected(String reason) {
|
||||
return '審査で差し戻されました:$reason。預けたPOPと手数料は返還済みです。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questSubmittedForReview => '公開を申請しました。審査後に公開されます。';
|
||||
|
||||
@override
|
||||
String get popReasonQuestFeeRefund => 'クエスト手数料の返還';
|
||||
|
||||
@override
|
||||
String get questGoalSteps => '目標歩数';
|
||||
}
|
||||
|
||||
@@ -2091,6 +2091,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get adNotFinished => '광고를 끝까지 보지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.';
|
||||
|
||||
@override
|
||||
String get adNotVerified => '광고 시청이 확인되지 않아 추가 보상은 없어요. 기본 보상은 그대로 받습니다.';
|
||||
|
||||
@override
|
||||
String get openSettings => '설정 열기';
|
||||
|
||||
@@ -2771,4 +2774,32 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get shakeShareResult => '결과 공유';
|
||||
|
||||
@override
|
||||
String get questCancel => '퀘스트 취소';
|
||||
|
||||
@override
|
||||
String get questCancelInReview =>
|
||||
'검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.';
|
||||
|
||||
@override
|
||||
String get questCancelPublished =>
|
||||
'공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.';
|
||||
|
||||
@override
|
||||
String get questUnderReview => '운영 검수 중이에요. 승인되면 공개됩니다.';
|
||||
|
||||
@override
|
||||
String questModerationRejected(String reason) {
|
||||
return '운영 검수에서 반려되었어요: $reason. 예치한 POP 과 수수료는 돌려드렸어요.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questSubmittedForReview => '공개 요청했어요. 운영 검수 후 공개됩니다.';
|
||||
|
||||
@override
|
||||
String get popReasonQuestFeeRefund => '퀘스트 수수료 반환';
|
||||
|
||||
@override
|
||||
String get questGoalSteps => '목표 걸음 수';
|
||||
}
|
||||
|
||||
@@ -2088,6 +2088,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get adNotFinished => '未看完广告,无额外奖励,基础奖励照常发放。';
|
||||
|
||||
@override
|
||||
String get adNotVerified => '未能确认广告观看,无额外奖励,基础奖励照常发放。';
|
||||
|
||||
@override
|
||||
String get openSettings => '打开设置';
|
||||
|
||||
@@ -2766,4 +2769,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get shakeShareResult => '分享结果';
|
||||
|
||||
@override
|
||||
String get questCancel => '取消任务';
|
||||
|
||||
@override
|
||||
String get questCancelInReview => '审核中取消将全额退还已存入的奖励POP和手续费。';
|
||||
|
||||
@override
|
||||
String get questCancelPublished => '取消已公开的任务将立即截止,剩余奖励POP退还,手续费不退。';
|
||||
|
||||
@override
|
||||
String get questUnderReview => '运营审核中,批准后公开。';
|
||||
|
||||
@override
|
||||
String questModerationRejected(String reason) {
|
||||
return '审核未通过:$reason。已退还存入的POP和手续费。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get questSubmittedForReview => '已提交公开申请,审核后公开。';
|
||||
|
||||
@override
|
||||
String get popReasonQuestFeeRefund => '任务手续费退还';
|
||||
|
||||
@override
|
||||
String get questGoalSteps => '目标步数';
|
||||
}
|
||||
|
||||
+10
-1
@@ -622,6 +622,7 @@
|
||||
"questStopBody": "当前进度将被重置并恢复初始状态。",
|
||||
"questStop": "中止",
|
||||
"adNotFinished": "未看完广告,无额外奖励,基础奖励照常发放。",
|
||||
"adNotVerified": "未能确认广告观看,无额外奖励,基础奖励照常发放。",
|
||||
"openSettings": "打开设置",
|
||||
"permStepsDenied": "步数权限已关闭,请在设置中允许“身体活动”。",
|
||||
"permStepsNeeded": "计步需要身体活动权限。",
|
||||
@@ -817,5 +818,13 @@
|
||||
"shakeJudged": "判定 {n} / {total}",
|
||||
"shakePlayHint": "播放视频并跟着节拍摇动手机",
|
||||
"shakeVideoError": "无法播放该视频",
|
||||
"shakeShareResult": "分享结果"
|
||||
"shakeShareResult": "分享结果",
|
||||
"questCancel": "取消任务",
|
||||
"questCancelInReview": "审核中取消将全额退还已存入的奖励POP和手续费。",
|
||||
"questCancelPublished": "取消已公开的任务将立即截止,剩余奖励POP退还,手续费不退。",
|
||||
"questUnderReview": "运营审核中,批准后公开。",
|
||||
"questModerationRejected": "审核未通过:{reason}。已退还存入的POP和手续费。",
|
||||
"questSubmittedForReview": "已提交公开申请,审核后公开。",
|
||||
"popReasonQuestFeeRefund": "任务手续费退还",
|
||||
"questGoalSteps": "目标步数"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get adNotFinished => _remote['adNotFinished'] ?? _base.adNotFinished;
|
||||
|
||||
@override
|
||||
String get adNotVerified => _remote['adNotVerified'] ?? _base.adNotVerified;
|
||||
|
||||
@override
|
||||
String get adPartyPack => _remote['adPartyPack'] ?? _base.adPartyPack;
|
||||
|
||||
@@ -1579,6 +1582,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get popReasonQuestFee => _remote['popReasonQuestFee'] ?? _base.popReasonQuestFee;
|
||||
|
||||
@override
|
||||
String get popReasonQuestFeeRefund => _remote['popReasonQuestFeeRefund'] ?? _base.popReasonQuestFeeRefund;
|
||||
|
||||
@override
|
||||
String get popReasonQuestMusicShake => _remote['popReasonQuestMusicShake'] ?? _base.popReasonQuestMusicShake;
|
||||
|
||||
@@ -1741,6 +1747,15 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questAuthorNote => _remote['questAuthorNote'] ?? _base.questAuthorNote;
|
||||
|
||||
@override
|
||||
String get questCancel => _remote['questCancel'] ?? _base.questCancel;
|
||||
|
||||
@override
|
||||
String get questCancelInReview => _remote['questCancelInReview'] ?? _base.questCancelInReview;
|
||||
|
||||
@override
|
||||
String get questCancelPublished => _remote['questCancelPublished'] ?? _base.questCancelPublished;
|
||||
|
||||
@override
|
||||
String get questCatCommunity => _remote['questCatCommunity'] ?? _base.questCatCommunity;
|
||||
|
||||
@@ -1816,6 +1831,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questGoalBpm => _remote['questGoalBpm'] ?? _base.questGoalBpm;
|
||||
|
||||
@override
|
||||
String get questGoalSteps => _remote['questGoalSteps'] ?? _base.questGoalSteps;
|
||||
|
||||
@override
|
||||
String get questGoalValue => _remote['questGoalValue'] ?? _base.questGoalValue;
|
||||
|
||||
@@ -1843,6 +1861,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String questMine(String count) => _fill('questMine', {'count': count}) ?? _base.questMine(count);
|
||||
|
||||
@override
|
||||
String questModerationRejected(String reason) => _fill('questModerationRejected', {'reason': reason}) ?? _base.questModerationRejected(reason);
|
||||
|
||||
@override
|
||||
String get questName => _remote['questName'] ?? _base.questName;
|
||||
|
||||
@@ -1972,6 +1993,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questSubmitted => _remote['questSubmitted'] ?? _base.questSubmitted;
|
||||
|
||||
@override
|
||||
String get questSubmittedForReview => _remote['questSubmittedForReview'] ?? _base.questSubmittedForReview;
|
||||
|
||||
@override
|
||||
String get questTabEmpty => _remote['questTabEmpty'] ?? _base.questTabEmpty;
|
||||
|
||||
@@ -2014,6 +2038,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get questTypeVote => _remote['questTypeVote'] ?? _base.questTypeVote;
|
||||
|
||||
@override
|
||||
String get questUnderReview => _remote['questUnderReview'] ?? _base.questUnderReview;
|
||||
|
||||
@override
|
||||
String get rankEmpty => _remote['rankEmpty'] ?? _base.rankEmpty;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user