149 lines
6.6 KiB
Dart
149 lines
6.6 KiB
Dart
/// 퀘스트 증거 수집 — 단말에서 얻는 값만 다룬다.
|
|
///
|
|
/// **여기서 판정하지 않는다.** 별첨 1 ③ 이 「보상 전량 서버검증 경유」를 요구하므로
|
|
/// 이 파일은 걸음 수와 좌표를 모아 넘기는 데까지만 관여하고, 완주·반경·조작 판정은
|
|
/// 서버(`fn-quest-verify-steps` · `fn-quest-verify-place`)가 다시 한다.
|
|
///
|
|
/// 저장소가 이 함수들을 부르고, 화면은 부르지 않는다. 테스트는 [FirestoreQuestRepository]
|
|
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
|
library;
|
|
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:pedometer/pedometer.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../core/data/backend.dart';
|
|
import '../../core/i18n/app_locale.dart';
|
|
|
|
|
|
|
|
/// 좌표 한 점. `fn-quest-verify-place` 가 받는 증거다.
|
|
class PlaceFix {
|
|
const PlaceFix({
|
|
required this.lat,
|
|
required this.lng,
|
|
required this.accuracyM,
|
|
required this.mocked,
|
|
});
|
|
|
|
final double lat;
|
|
final double lng;
|
|
final double accuracyM;
|
|
|
|
/// 모의 위치 앱으로 만든 좌표인가. 설계서 §8 이 「mock location 차단」을
|
|
/// 판정 항목으로 두었으므로 **판정하지 않고 서버에 그대로 실어 보낸다.**
|
|
final bool mocked;
|
|
}
|
|
|
|
/// 부팅 이후 누적 걸음 수 스트림을 내는 함수.
|
|
typedef StepCountProbe = Stream<int> Function();
|
|
|
|
/// 현재 좌표 한 점을 내는 함수.
|
|
typedef PlaceProbe = Future<PlaceFix> Function();
|
|
|
|
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
|
|
///
|
|
/// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로
|
|
/// 증가분을 낸다([FirestoreQuestRepository.progressStream]).
|
|
///
|
|
/// Android 는 API 29 부터 `ACTIVITY_RECOGNITION` 이 런타임 권한이다. 권한이 없으면
|
|
/// 센서 채널이 조용히 빈 스트림으로 남아 「진행률이 0 에서 멈춘다」로만 보이므로,
|
|
/// 구독 전에 권한을 먼저 받고 거부 사유를 카탈로그 코드로 올린다.
|
|
Stream<int> deviceStepCount() async* {
|
|
await _requireActivityPermission();
|
|
yield* Pedometer.stepCountStream.map((event) => event.steps);
|
|
}
|
|
|
|
Future<void> _requireActivityPermission() async {
|
|
final status = await Permission.activityRecognition.request();
|
|
if (status.isGranted || status.isLimited) return;
|
|
throw BackendException(
|
|
'E-PERM-01',
|
|
status.isPermanentlyDenied
|
|
? appL10n.permStepsDenied
|
|
: appL10n.permStepsNeeded,
|
|
);
|
|
}
|
|
|
|
/// 현재 좌표 — `geolocator`.
|
|
///
|
|
/// 별첨 B **B-27** 이 「탐색은 가능하나 보상 인증은 GPS/Geofence 필수」로 확정했으므로
|
|
/// 권한이 없을 때 막는 것은 **인증뿐**이다. 목록·상세는 권한 없이 열린다.
|
|
Future<PlaceFix> devicePlaceFix() async {
|
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
|
throw BackendException('E-PERM-01', appL10n.permLocationOff);
|
|
}
|
|
var permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
}
|
|
if (permission == LocationPermission.denied) {
|
|
throw BackendException('E-PERM-01', appL10n.permLocationNeeded);
|
|
}
|
|
if (permission == LocationPermission.deniedForever) {
|
|
throw BackendException('E-PERM-01', appL10n.permLocationDenied);
|
|
}
|
|
|
|
// 반경 판정을 서버가 하므로 정확도를 높게 요청한다. 낮은 정확도로 받은 좌표는
|
|
// 반경 100m(B-27) 판정에서 오차가 그대로 판정 결과가 된다.
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high),
|
|
);
|
|
return PlaceFix(
|
|
lat: position.latitude,
|
|
lng: position.longitude,
|
|
accuracyM: position.accuracy,
|
|
mocked: position.isMocked,
|
|
);
|
|
}
|
|
|
|
/// 권한을 영구 거부한 사용자를 설정 화면으로 보낸다. 화면의 「설정 열기」가 부른다.
|
|
Future<void> openQuestPermissionSettings() => openAppSettings();
|
|
|
|
/// 오늘 걸은 수 — **하루 단위 기준선**.
|
|
///
|
|
/// 걸음 센서가 주는 값은 [deviceStepCount] 설명대로 **부팅 이후 누적**이다. 정책 원장
|
|
/// **B-26** 의 구간(1 천·5 천·1 만·1.5 만보)은 **그 날 걸은 수** 기준이므로, 하루가
|
|
/// 시작될 때의 누적값을 기준선으로 남겨 두고 그 차이를 오늘치로 본다.
|
|
///
|
|
/// 기준선을 화면이나 세션이 아니라 **단말에 남기는 이유**는 앱을 껐다 켜는 일이 흔하기
|
|
/// 때문이다. 세션에 두면 앱을 다시 열 때마다 오늘치가 0 으로 돌아가 구간을 다시 받게
|
|
/// 되는 것처럼 보이고(실제 지급은 서버가 막는다) 진행률도 사실과 달라진다.
|
|
///
|
|
/// 날짜 경계는 **KST** 다. 서버의 `dayKey()` 와 같은 기준이어야 화면의 「오늘」과 서버가
|
|
/// 세는 「오늘」이 어긋나지 않는다.
|
|
class WalkBaseline {
|
|
WalkBaseline({SharedPreferences? preferences}) : _preferences = preferences;
|
|
|
|
static const _dayKeyName = 'fanuzu.walk.baseline.day';
|
|
static const _stepsKeyName = 'fanuzu.walk.baseline.steps';
|
|
|
|
SharedPreferences? _preferences;
|
|
|
|
/// 누적 걸음 [cumulative] 에서 오늘치를 낸다.
|
|
///
|
|
/// ponytail: 단말을 재부팅하면 센서 누적이 0 으로 돌아가므로 기준선을 다시 잡는다 —
|
|
/// 재부팅 전에 걸은 만큼은 오늘치에서 빠진다. 서버가 판정하는 값이 줄어드는 쪽이라
|
|
/// 과지급으로 이어지지 않는다. 하루치 합계를 정확히 맞추려면 Health Connect·HealthKit
|
|
/// (선검증리포트의 `health` 13.3.1)으로 바꿔야 하고, 그때 최소 지원 OS 를 다시 잰다.
|
|
Future<int> todaySteps(int cumulative, {DateTime? now}) async {
|
|
final prefs = _preferences ??= await SharedPreferences.getInstance();
|
|
final today = kstDayKey(now ?? DateTime.now());
|
|
final storedDay = prefs.getString(_dayKeyName);
|
|
var baseline = prefs.getInt(_stepsKeyName);
|
|
|
|
// 날이 바뀌었거나(어제 기준선) 센서가 되감겼다(재부팅). 둘 다 기준선을 다시 잡는다.
|
|
if (storedDay != today || baseline == null || baseline > cumulative) {
|
|
baseline = cumulative;
|
|
await prefs.setString(_dayKeyName, today);
|
|
await prefs.setInt(_stepsKeyName, baseline);
|
|
}
|
|
return cumulative - baseline;
|
|
}
|
|
}
|
|
|
|
/// 일일 한도의 리셋 단위 — 서버 `dayKey()` 와 같은 기준(KST)이다.
|
|
String kstDayKey(DateTime at) =>
|
|
at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
|