/// 위치기반 서비스 약관 — 위치기능 **최초 사용 직전**에 받는 동의. /// /// 통합검토회신 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 granted(); /// 동의(true) · 철회(false)를 기록한다. 철회도 새 행으로 남는다(서버 append-only). Future record(bool granted); } class FirestoreLocationConsent implements LocationConsent { FirestoreLocationConsent(this._backend); final Backend _backend; /// 서버 `hasLocationConsent` 와 같은 판정 — `consentState.location` 이 없으면 예전 가입 화면 /// (위치 약관이 가입 필수였던 0.2.0 이전)의 동의 행으로 본다. @override Future granted() async { final uid = _backend.requireUid; final user = (await _backend.db.doc('users/$uid').get()).data() ?? const {}; 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 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 = []; @override Future granted() async => value; @override Future record(bool granted) async { records.add(granted); value = granted; } } /// 위치기능을 쓰기 직전에 부른다. 이미 동의했으면 묻지 않고 true, 처음이면 동의 창을 띄워 /// 동의를 기록한 뒤 true. 「나중에」 · 기록 실패면 false — 호출부는 그 위치기능만 건너뛴다. Future 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 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, ); }