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,
);
}