1차 보완(0.1.1+14) 내용 합치기
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/// 위치기반 서비스 약관 — 위치기능 **최초 사용 직전**에 받는 동의.
|
||||
///
|
||||
/// 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02** — 가입 때 위치기반 약관을
|
||||
/// 일괄 필수로 받지 않는다. 위치 약관 동의와 OS 위치권한은 위치기능을 처음 쓰기 전에 **각각** 처리하고,
|
||||
/// 거부해도 일반 기능은 막지 않는다. 그래서 이 동의는 위치 Quest 인증 · 장소 선택 지도의 현재 위치처럼
|
||||
/// 기기 위치를 쓰는 지점에서만 묻고, OS 권한은 그 뒤 [devicePlaceFix] 가 따로 요청한다.
|
||||
///
|
||||
/// 기록은 서버 `fnConsentRecord`(`users/{uid}/consents` append-only 행 + `consentState.location`)가 하고,
|
||||
/// 위치 Quest 인증(`fnQuestVerifyPlace`)도 같은 상태를 다시 본다. 철회는 설정 화면의 토글이다.
|
||||
///
|
||||
/// Figma 에는 이 시점의 동의 화면이 없다(`179:24230` 은 가입 약관). 앱 공통 확인 창([showConfirmDialog])
|
||||
/// 틀에 약관 칸을 넣어 쓴다.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
/// 동의 기록에 남는 위치기반 약관 버전. 🟡 임시 — 발주사 확정 문안(R02) 수령 전 자리 표시 문구다.
|
||||
const locationTermsVersion = 'draft-1';
|
||||
|
||||
/// 위치기반 서비스 동의 상태 · 기록 경계.
|
||||
abstract interface class LocationConsent {
|
||||
Future<bool> granted();
|
||||
|
||||
/// 동의(true) · 철회(false)를 기록한다. 철회도 새 행으로 남는다(서버 append-only).
|
||||
Future<void> record(bool granted);
|
||||
}
|
||||
|
||||
class FirestoreLocationConsent implements LocationConsent {
|
||||
FirestoreLocationConsent(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
/// 서버 `hasLocationConsent` 와 같은 판정 — `consentState.location` 이 없으면 예전 가입 화면
|
||||
/// (위치 약관이 가입 필수였던 0.2.0 이전)의 동의 행으로 본다.
|
||||
@override
|
||||
Future<bool> granted() async {
|
||||
final uid = _backend.requireUid;
|
||||
final user =
|
||||
(await _backend.db.doc('users/$uid').get()).data() ??
|
||||
const <String, dynamic>{};
|
||||
final state = user['consentState'];
|
||||
if (state is Map && state['location'] is Map) {
|
||||
return (state['location'] as Map)['granted'] == true;
|
||||
}
|
||||
final version = user['termsVersion'];
|
||||
if (version is! String || version.isEmpty) return false;
|
||||
return (await _backend.db
|
||||
.doc('users/$uid/consents/location_${version}_grant')
|
||||
.get())
|
||||
.exists;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> record(bool granted) => _backend.call('fnConsentRecord', {
|
||||
'docType': 'location',
|
||||
'docVersion': locationTermsVersion,
|
||||
'granted': granted,
|
||||
});
|
||||
}
|
||||
|
||||
/// 목 모드 · 테스트용 — 앱 안에서 하나를 같이 쓴다(퀘스트 · 설정 화면이 같은 상태를 본다).
|
||||
class SampleLocationConsent implements LocationConsent {
|
||||
SampleLocationConsent([this.value = false]);
|
||||
|
||||
static final shared = SampleLocationConsent();
|
||||
|
||||
bool value;
|
||||
final records = <bool>[];
|
||||
|
||||
@override
|
||||
Future<bool> granted() async => value;
|
||||
|
||||
@override
|
||||
Future<void> record(bool granted) async {
|
||||
records.add(granted);
|
||||
value = granted;
|
||||
}
|
||||
}
|
||||
|
||||
/// 위치기능을 쓰기 직전에 부른다. 이미 동의했으면 묻지 않고 true, 처음이면 동의 창을 띄워
|
||||
/// 동의를 기록한 뒤 true. 「나중에」 · 기록 실패면 false — 호출부는 그 위치기능만 건너뛴다.
|
||||
Future<bool> ensureLocationConsent(
|
||||
BuildContext context,
|
||||
LocationConsent consent,
|
||||
) async {
|
||||
try {
|
||||
if (await consent.granted()) return true;
|
||||
} catch (_) {
|
||||
// 상태를 못 읽었으면 묻는다 — 동의는 다시 기록해도 행이 하나 더 남을 뿐이다.
|
||||
}
|
||||
if (!context.mounted) return false;
|
||||
final agreed = await showLocationConsentDialog(context);
|
||||
if (!agreed) return false;
|
||||
try {
|
||||
await consent.record(true);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.commonRequestFailed),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 동의 창 — 제목 · 사용 목적 · 약관 본문 칸 · 「나중에 / 동의」. 동의면 true.
|
||||
Future<bool> showLocationConsentDialog(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return showConfirmDialog(
|
||||
context,
|
||||
l10n.termsLocation,
|
||||
body: l10n.locationConsentBody,
|
||||
content: Container(
|
||||
key: const ValueKey('location-terms-body'),
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
padding: const EdgeInsets.all(AppSpace.s3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF545454)),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
l10n.termsLocationBody,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontFamilyFallback: [AppFonts.body],
|
||||
fontSize: 12,
|
||||
height: 1.36,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
cancelLabel: l10n.locationConsentLater,
|
||||
confirmLabel: l10n.locationConsentAgree,
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,13 @@ class PlacePickerScreen extends StatefulWidget {
|
||||
super.key,
|
||||
this.mapBuilder = _googleMap,
|
||||
this.lastKnown = _deviceLastKnown,
|
||||
this.useDeviceLocation = true,
|
||||
});
|
||||
|
||||
/// 기기 위치(마지막 위치 · 현재 위치)를 쓸지. 위치기반 서비스 약관에 동의하지 않았으면 false 다 —
|
||||
/// 지도에서 직접 고르는 것은 그대로 된다(Q-E1 · R02 「거부해도 일반 기능 이용 가능」).
|
||||
final bool useDeviceLocation;
|
||||
|
||||
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
|
||||
final PickerMapBuilder mapBuilder;
|
||||
|
||||
@@ -68,6 +73,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.useDeviceLocation) {
|
||||
_start = PlacePickerScreen.fallbackCenter;
|
||||
return;
|
||||
}
|
||||
_startFromLastKnown();
|
||||
_locate();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../core/data/server_clock.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'place_picker_screen.dart';
|
||||
import 'location_consent.dart';
|
||||
import 'quest_data.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -441,9 +442,13 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
final _extraVerify = <String>{};
|
||||
|
||||
Future<void> _pickPlace() async {
|
||||
// 지도의 현재 위치는 위치기능이다 — 위치기반 약관에 먼저 동의받는다(Q-E1 · R02). 거부해도 지도에서
|
||||
// 직접 고를 수 있다.
|
||||
final located = await ensureLocationConsent(context, widget.repository.locationConsent);
|
||||
if (!mounted) return;
|
||||
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
|
||||
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
|
||||
SheetRoute(builder: (_) => const PlacePickerScreen()),
|
||||
SheetRoute(builder: (_) => PlacePickerScreen(useDeviceLocation: located)),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _place = picked);
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import 'place_picker_screen.dart' show PlacePickerScreen;
|
||||
import 'location_consent.dart';
|
||||
import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
|
||||
|
||||
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
|
||||
@@ -684,6 +685,9 @@ class QuestDraft {
|
||||
}
|
||||
|
||||
abstract interface class QuestRepository {
|
||||
/// 위치기반 서비스 약관 동의(Q-E1 · R02) — 위치 Quest 인증 · 장소 선택의 현재 위치 직전에 확인한다.
|
||||
LocationConsent get locationConsent;
|
||||
|
||||
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
|
||||
///
|
||||
/// [limit] 장보다 적게 돌려주면 더 읽을 문서가 없다는 뜻이다 — 화면은 이것으로 끝을 판정한다.
|
||||
@@ -798,6 +802,9 @@ abstract interface class QuestRepository {
|
||||
class SampleQuestRepository implements QuestRepository {
|
||||
const SampleQuestRepository();
|
||||
|
||||
@override
|
||||
LocationConsent get locationConsent => SampleLocationConsent.shared;
|
||||
|
||||
/// 원장 C-01 매핑 중 사용자 생성 초기값(C-02 A안)으로 열린 활동만.
|
||||
static QuestCatalog get _catalog => QuestCatalog(
|
||||
categories: [
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../planet/moderation.dart';
|
||||
import 'music_shake_play_screen.dart';
|
||||
import 'quest_ad_screens.dart';
|
||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||
import 'location_consent.dart';
|
||||
import 'quest_data.dart';
|
||||
import 'quest_evidence.dart';
|
||||
import 'qr_scan_screen.dart';
|
||||
@@ -1025,6 +1026,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
// 위치 Quest 의 추가 인증(C-03) — 고른 수단의 증거를 먼저 모은다. 취소하면 아무것도 보내지 않는다.
|
||||
var proof = const PlaceProof();
|
||||
if (detail.card.type == QuestType.place && detail.card.endsAt == null) {
|
||||
// 위치기반 서비스 약관 — 위치기능 최초 사용 직전(Q-E1 · R02). 거부하면 이 인증만 하지 않는다.
|
||||
// OS 위치권한은 그 뒤 좌표를 읽을 때 따로 묻는다.
|
||||
if (!await ensureLocationConsent(context, widget.repository.locationConsent)) {
|
||||
if (mounted) _toast(context.l10n.locationConsentNeeded);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
final collected = await _placeProof(detail.verifyMethods);
|
||||
if (collected == null || !mounted) return;
|
||||
proof = collected;
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:firebase_storage/firebase_storage.dart' show SettableMetadata;
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import 'location_consent.dart';
|
||||
import 'quest_data.dart';
|
||||
import 'quest_evidence.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -40,6 +41,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
_baseline = baseline ?? WalkBaseline();
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
@override
|
||||
late final LocationConsent locationConsent = FirestoreLocationConsent(_backend);
|
||||
|
||||
final StepCountProbe _stepCount;
|
||||
final TodayStepsProbe _todaySteps;
|
||||
final PlaceProbe _placeFix;
|
||||
|
||||
Reference in New Issue
Block a user