/// 퀘스트 증거 수집 — 단말에서 얻는 값만 다룬다. /// /// **여기서 판정하지 않는다.** 별첨 1 ③ 이 「보상 전량 서버검증 경유」를 요구하므로 /// 이 파일은 걸음 수와 좌표를 모아 넘기는 데까지만 관여하고, 완주·반경·조작 판정은 /// 서버(`fn-quest-verify-steps` · `fn-quest-verify-place`)가 다시 한다. /// /// 저장소가 이 함수들을 부르고, 화면은 부르지 않는다. 테스트는 [FirestoreQuestRepository] /// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다. library; import 'dart:math' as math; import 'package:geolocator/geolocator.dart'; import 'package:pedometer/pedometer.dart'; import 'package:sensors_plus/sensors_plus.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 Function(); /// 현재 좌표 한 점을 내는 함수. typedef PlaceProbe = Future Function(); /// 흔들기 한 번마다 그 시각을 내는 스트림. typedef ShakeProbe = Stream Function(); /// 흔들기 한 번을 세는 규칙 — Music Shake · 이클립스 방어 · HERO TIME 이 함께 쓴다. /// /// **단위는 두 플랫폼 모두 m/s²(중력 포함)다.** sensors_plus 가 iOS 의 g 값에 9.81 을 /// 곱해 넘긴다. 플랫폼 차이는 **표본 주기**였다(2026-10-05 실기기 — 같은 시간 흔들어 /// iPhone 16 · Galaxy 63). 기본 주기(200ms)를 iOS 는 그대로 지켜 초당 5 표본만 오고, /// Android 는 요청을 힌트로만 받아 훨씬 촘촘히 보낸다. 흔들기의 가속도 정점은 수십 ms /// 라 5Hz 표본은 대부분을 놓친다. 그래서 [sampling] 을 명시하고(약 50Hz), /// 정점 하나를 한 번만 세도록 [rearm] 아래로 내려와야 다시 센다(히스테리시스). class ShakeDetector { ShakeDetector({ this.threshold = 18, this.rearm = 13, this.gap = const Duration(milliseconds: 220), }); /// 흔들기로 보는 가속도 크기(m/s²). 정지 상태는 중력 9.8 이다. final double threshold; /// 이 아래로 내려와야 다음 흔들기를 센다. 한 정점이 표본 여럿에 걸쳐도 한 번이다. final double rearm; /// 두 흔들기 사이 최소 간격. 이보다 촘촘한 값은 같은 동작의 진동이다. final Duration gap; /// 표본 주기. 두 플랫폼이 같은 주기로 보도록 기본값(200ms)을 쓰지 않는다. static const sampling = SensorInterval.gameInterval; bool _armed = true; DateTime _last = DateTime.fromMillisecondsSinceEpoch(0); /// 표본 하나를 넣는다. 흔들기 한 번으로 셀 표본이면 true. bool add(double x, double y, double z, DateTime at) { final magnitude = math.sqrt(x * x + y * y + z * z); if (!magnitude.isFinite) return false; if (magnitude < rearm) { _armed = true; return false; } if (!_armed || magnitude < threshold || at.difference(_last) < gap) { return false; } _armed = false; _last = at; return true; } } /// 단말 가속도계에서 흔들기 한 번마다 그 시각을 낸다. Stream deviceShakes() { final detector = ShakeDetector(); return accelerometerEventStream(samplingPeriod: ShakeDetector.sampling) .map((e) => (e, DateTime.now())) .where((s) => detector.add(s.$1.x, s.$1.y, s.$1.z, s.$2)) .map((s) => s.$2); } /// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24). /// /// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것, /// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시 /// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다. class ShakeJudge { ShakeJudge({required this.bpm, required this.segments, required this.start}) : assert(bpm > 0 && segments > 0); final double bpm; final int segments; final DateTime start; /// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다. static const perfectRatio = 0.2; final _shakes = []; Duration get beat => Duration(microseconds: (60e6 / bpm).round()); /// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각. DateTime get end => start.add(beat * segments - beat ~/ 2); void add(DateTime at) => _shakes.add(at); /// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키. Map judgement() { var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0; final beatUs = beat.inMicroseconds; for (var i = 0; i < segments; i++) { final at = start.microsecondsSinceEpoch + beatUs * i; final best = _shakes .map((s) => (s.microsecondsSinceEpoch - at).abs()) .where((d) => d < beatUs / 2) .fold(null, (m, d) => m == null || d < m ? d : m); if (best == null) { miss++; combo = 0; continue; } best <= beatUs * perfectRatio ? perfect++ : good++; maxCombo = math.max(maxCombo, ++combo); } return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo}; } } /// 걸음 센서 — `pedometer` 의 `stepCountStream`. /// /// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로 /// 증가분을 낸다([FirestoreQuestRepository.progressStream]). /// /// Android 는 API 29 부터 `ACTIVITY_RECOGNITION` 이 런타임 권한이다. 권한이 없으면 /// 센서 채널이 조용히 빈 스트림으로 남아 「진행률이 0 에서 멈춘다」로만 보이므로, /// 구독 전에 권한을 먼저 받고 거부 사유를 카탈로그 코드로 올린다. Stream deviceStepCount() async* { await _requireActivityPermission(); yield* Pedometer.stepCountStream.map((event) => event.steps); } Future _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 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 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 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);