1차 보완(0.1.1+14) 내용 합치기

This commit is contained in:
Sn-Kinos
2026-10-16 17:58:55 +09:00
62 changed files with 2480 additions and 447 deletions
@@ -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);
+7
View File
@@ -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;