439 lines
18 KiB
Dart
439 lines
18 KiB
Dart
/// 퀘스트 증거 수집 — 단말에서 얻는 값만 다룬다.
|
|
///
|
|
/// **여기서 판정하지 않는다.** 별첨 1 ③ 이 「보상 전량 서버검증 경유」를 요구하므로
|
|
/// 이 파일은 걸음 수와 좌표를 모아 넘기는 데까지만 관여하고, 완주·반경·조작 판정은
|
|
/// 서버(`fn-quest-verify-steps` · `fn-quest-verify-place`)가 다시 한다.
|
|
///
|
|
/// 저장소가 이 함수들을 부르고, 화면은 부르지 않는다. 테스트는 [FirestoreQuestRepository]
|
|
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
|
library;
|
|
|
|
import 'dart:io' show Platform;
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:health/health.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<int> Function();
|
|
|
|
/// 현재 좌표 한 점을 내는 함수.
|
|
typedef PlaceProbe = Future<PlaceFix> Function();
|
|
|
|
/// 흔들기 한 번마다 그 시각을 내는 스트림.
|
|
typedef ShakeProbe = Stream<DateTime> 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<DateTime> 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 판정 한 박의 결과 — PRD-04 「Shake 판정: Perfect, Good, Miss, Combo」.
|
|
enum ShakeHit { perfect, good, miss }
|
|
|
|
/// Music Shake 판정 창 — `config/policy.musicShake`(운영값). 단위는 **박 길이 대비 비율**이다.
|
|
///
|
|
/// 🟡 **임시값 — 회신 대기.** PRD-04 · 상세정의 v1.6.1 §20 · 별첨 B 「Music Shake」 어디에도 판정 창
|
|
/// 수치가 없다(해석 확인 요청 2026-10-07 Music Shake **Q-MS-04**). 서버 `DEFAULT_POLICY.musicShake`
|
|
/// 와 같은 값이며, 운영 문서가 덮으면 그 값을 쓴다.
|
|
class ShakeWindows {
|
|
const ShakeWindows({this.perfect = 0.2, this.good = 0.5});
|
|
|
|
factory ShakeWindows.from(Map<String, dynamic> policy) {
|
|
final raw = policy['musicShake'];
|
|
final m = raw is Map ? raw : const {};
|
|
double read(String key, double fallback) {
|
|
final v = m[key];
|
|
return v is num && v > 0 ? v.toDouble() : fallback;
|
|
}
|
|
|
|
const d = ShakeWindows();
|
|
return ShakeWindows(
|
|
perfect: read('perfectWindow', d.perfect),
|
|
good: read('goodWindow', d.good),
|
|
);
|
|
}
|
|
|
|
final double perfect;
|
|
final double good;
|
|
}
|
|
|
|
/// 판정 중간값 — 화면이 그리는 수치.
|
|
typedef ShakeLive = ({
|
|
int perfect,
|
|
int good,
|
|
int miss,
|
|
int combo,
|
|
int maxCombo,
|
|
int judged,
|
|
ShakeHit? last,
|
|
});
|
|
|
|
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(정책 원장 B-24).
|
|
///
|
|
/// 시각은 **영상 재생 시각**(0 = 영상 처음)이다 — 공식 YouTube 영상을 보며 흔드는 퀘스트라
|
|
/// (PRD-04) 박은 영상에 붙는다. 곡의 박(`quests.bpm`)마다 한 구간이고 첫 박은 `quests.firstBeatMs` 이며(판 길이 `totalSegments` 는 곡 전체를 덮도록 운영자가 정한다), 흔들기는 가장 가까운 박
|
|
/// 하나에만 붙는다. 박과의 거리가 [ShakeWindows.perfect] 박 이내면 perfect, [ShakeWindows.good]
|
|
/// 박 이내면 good, 없으면 miss. **성공 판정은 서버가 다시 한다** — 표가 스스로 모순되면(합 ≠ 구간
|
|
/// 수) 서버가 끊는다.
|
|
class ShakeJudge {
|
|
ShakeJudge({
|
|
required this.bpm,
|
|
required this.segments,
|
|
this.firstBeat = Duration.zero,
|
|
this.windows = const ShakeWindows(),
|
|
List<double> hits = const [],
|
|
this.position = Duration.zero,
|
|
}) : assert(bpm > 0 && segments > 0) {
|
|
// 일시 정지에서 「이어하기」 — 멈추기 전 판정을 그대로 잇는다(정책 원장 C-83).
|
|
for (var i = 0; i < hits.length && i < segments; i++) {
|
|
if (hits[i] >= 0) _best[i] = hits[i];
|
|
}
|
|
}
|
|
|
|
final double bpm;
|
|
final int segments;
|
|
|
|
/// `quests.firstBeatMs` — 영상에서 첫 박이 오는 시각. 박 i 는 `firstBeat + i * 60/bpm` 이다.
|
|
final Duration firstBeat;
|
|
final ShakeWindows windows;
|
|
|
|
/// 마지막으로 본 영상 시각 — 진행 화면을 나가면 「이어하기」의 시작점이 된다.
|
|
Duration position;
|
|
|
|
/// 박 번호 → 가장 가까운 흔들기의 거리(박 비율).
|
|
final _best = <int, double>{};
|
|
|
|
/// 박마다 [_best] — 흔들기가 없으면 -1. 일시 정지 세션에 남긴다.
|
|
List<double> get hits => [for (var i = 0; i < segments; i++) _best[i] ?? -1];
|
|
|
|
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
|
|
|
/// 마지막 박의 판정 창이 닫히는 영상 시각.
|
|
Duration get end =>
|
|
firstBeat +
|
|
Duration(
|
|
microseconds: (beat.inMicroseconds * (segments - 1 + windows.good)).round(),
|
|
);
|
|
|
|
/// 영상 시각 [at] 이 첫 박에서 몇 박 떨어졌나.
|
|
double _beats(Duration at) =>
|
|
(at - firstBeat).inMicroseconds / beat.inMicroseconds;
|
|
|
|
/// 영상 시각 [at] 의 흔들기 한 번.
|
|
void add(Duration at) {
|
|
final pos = _beats(at);
|
|
final i = pos.round();
|
|
if (i < 0 || i >= segments) return;
|
|
final d = (pos - i).abs();
|
|
if (d > windows.good) return;
|
|
final prev = _best[i];
|
|
if (prev == null || d < prev) _best[i] = d;
|
|
}
|
|
|
|
/// 판정 창이 닫힌 박만 센다 — [now] 는 영상 시각.
|
|
ShakeLive live(Duration now) {
|
|
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0, judged = 0;
|
|
ShakeHit? last;
|
|
final pos = _beats(now);
|
|
for (var i = 0; i < segments && i + windows.good <= pos; i++) {
|
|
judged++;
|
|
final d = _best[i];
|
|
if (d == null) {
|
|
miss++;
|
|
combo = 0;
|
|
last = ShakeHit.miss;
|
|
continue;
|
|
}
|
|
if (d <= windows.perfect) {
|
|
perfect++;
|
|
last = ShakeHit.perfect;
|
|
} else {
|
|
good++;
|
|
last = ShakeHit.good;
|
|
}
|
|
maxCombo = math.max(maxCombo, ++combo);
|
|
}
|
|
return (
|
|
perfect: perfect,
|
|
good: good,
|
|
miss: miss,
|
|
combo: combo,
|
|
maxCombo: maxCombo,
|
|
judged: judged,
|
|
last: last,
|
|
);
|
|
}
|
|
|
|
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키. 아직 오지 않은 박은 miss 다.
|
|
Map<String, int> judgement() {
|
|
final v = live(end);
|
|
return {
|
|
'perfect': v.perfect,
|
|
'good': v.good,
|
|
'miss': v.miss + segments - v.judged,
|
|
'maxCombo': v.maxCombo,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// YouTube 링크에서 영상 ID 를 꺼낸다 — `watch?v=` · `youtu.be/` · `/shorts/` · `/embed/` ·
|
|
/// `music.youtube.com`. YouTube 링크가 아니면 null. 서버 `videoIdOf` 와 같은 규칙.
|
|
String? youtubeVideoId(String url) {
|
|
final u = Uri.tryParse(url.trim());
|
|
if (u == null) return null;
|
|
final host = u.host.toLowerCase();
|
|
String? id;
|
|
if (host == 'youtu.be') {
|
|
id = u.pathSegments.firstOrNull;
|
|
} else if (host == 'youtube.com' || host.endsWith('.youtube.com')) {
|
|
final seg = u.pathSegments;
|
|
id = u.queryParameters['v'] ??
|
|
(seg.length >= 2 && const {'shorts', 'embed', 'live'}.contains(seg[0])
|
|
? seg[1]
|
|
: null);
|
|
}
|
|
return id != null && RegExp(r'^[A-Za-z0-9_-]{11}$').hasMatch(id) ? id : null;
|
|
}
|
|
|
|
/// 걸음 센서 — `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();
|
|
|
|
/// 오늘(KST 0시~지금) 걸은 수를 내는 함수. 쓸 수 없으면 null — 기기 센서([StepCountProbe])로 대체한다.
|
|
typedef TodayStepsProbe = Future<int?> Function();
|
|
|
|
/// 오늘 0시(KST) — 서버 `dayKey()` 와 같은 날짜 경계.
|
|
DateTime kstMidnight(DateTime at) {
|
|
final kst = at.toUtc().add(const Duration(hours: 9));
|
|
return DateTime.utc(kst.year, kst.month, kst.day).subtract(const Duration(hours: 9));
|
|
}
|
|
|
|
/// 권한 확인은 앱이 떠 있는 동안 한 번만 한다. 거부되면 다음 실행에서 다시 묻는다.
|
|
Future<bool>? _healthAuthorized;
|
|
Health? _health;
|
|
|
|
/// 오늘 걸음 — **Health Connect(Android) · HealthKit(iOS)** 의 그 날 0시(KST)부터 지금까지 합계.
|
|
///
|
|
/// 견적서 「만보기(Health API)」 · 별첨 1 ③-1 「Health 권한 동의 → 걸음 집계」 · 정책 원장 **B-26**
|
|
/// 「권한 거부 시 앱 자체 집계 전환」. 기기 걸음 센서는 앱을 연 뒤부터만 세므로(앱 열기 전 걸음 누락 —
|
|
/// 대조표 B-02) 이 값이 1차 경로이고, Health 를 쓸 수 없을 때(Health Connect 미설치 · 권한 거부 ·
|
|
/// HealthKit 미지원)만 null 을 내 센서 경로로 넘긴다. 판정은 여전히 서버가 한다.
|
|
///
|
|
/// 🟡 **임시값 — 회신 대기**(해석 확인 요청 영역B Q-B-09): 사용자가 건강 앱에 **직접 입력한 걸음은
|
|
/// 빼고** 센다(`includeManualEntry: false`). 자료에 정의가 없고, 넣으면 손으로 숫자를 적어 구간을
|
|
/// 받는다. Android 는 기록 앱이 입력 방식을 남기지 않으면 걸러지지 않는다(플러그인 문서).
|
|
///
|
|
/// ponytail: iOS 는 읽기 권한 거부를 알려 주지 않는다(HealthKit 개인정보 정책 — 거부해도 0 이 온다).
|
|
/// 그래서 iOS 에서 0 이면 센서 경로로 넘긴다. 실제로 0 걸음인 날은 센서도 0 이라 결과가 같다.
|
|
Future<int?> deviceTodaySteps() async {
|
|
try {
|
|
final health = _health ??= Health();
|
|
final ok = await (_healthAuthorized ??= () async {
|
|
await health.configure();
|
|
if (Platform.isAndroid &&
|
|
await health.getHealthConnectSdkStatus() != HealthConnectSdkStatus.sdkAvailable) {
|
|
return false;
|
|
}
|
|
const types = [HealthDataType.STEPS];
|
|
const access = [HealthDataAccess.READ];
|
|
return await health.hasPermissions(types, permissions: access) == true ||
|
|
await health.requestAuthorization(types, permissions: access);
|
|
}());
|
|
if (!ok) {
|
|
_healthAuthorized = null;
|
|
return null;
|
|
}
|
|
final now = DateTime.now();
|
|
final steps = await health.getTotalStepsInInterval(
|
|
kstMidnight(now),
|
|
now,
|
|
includeManualEntry: false,
|
|
);
|
|
if (steps == null || (Platform.isIOS && steps == 0)) return null;
|
|
return steps;
|
|
} catch (_) {
|
|
_healthAuthorized = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 오늘 걸은 수 — **하루 단위 기준선**.
|
|
///
|
|
/// 걸음 센서가 주는 값은 [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);
|