147 lines
5.3 KiB
Dart
147 lines
5.3 KiB
Dart
/// 위치기반 서비스 약관 — 위치기능 **최초 사용 직전**에 받는 동의.
|
|
///
|
|
/// 통합검토회신 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,
|
|
);
|
|
}
|