걸음 수 Health Connect·HealthKit · 위치 퀘스트 사진·QR 인증 · 행성 형태 확정

This commit is contained in:
Sn-Kinos
2026-10-05 23:57:01 +09:00
parent dc9861df38
commit dce2f64362
44 changed files with 930 additions and 66 deletions
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart' show SettableMetadata;
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
@@ -28,16 +29,19 @@ class FirestoreQuestRepository implements QuestRepository {
FirestoreQuestRepository(
this._backend, {
StepCountProbe stepCount = deviceStepCount,
TodayStepsProbe todaySteps = deviceTodaySteps,
PlaceProbe placeFix = devicePlaceFix,
ShakeProbe shakes = deviceShakes,
WalkBaseline? baseline,
}) : _stepCount = stepCount,
_todaySteps = todaySteps,
_placeFix = placeFix,
_shakes = shakes,
_baseline = baseline ?? WalkBaseline();
final Backend _backend;
final StepCountProbe _stepCount;
final TodayStepsProbe _todaySteps;
final PlaceProbe _placeFix;
final ShakeProbe _shakes;
@@ -249,9 +253,23 @@ class FirestoreQuestRepository implements QuestRepository {
moderationReason: quest['moderation'] is Map
? (quest['moderation'] as Map)['reason'] as String?
: null,
verifyMethods: _verifyMethodsOf(quest),
qrCode: quest.strOr('authorUid') == uid && _verifyMethodsOf(quest).contains('qr')
? await db
.doc('questSecrets/$questId')
.get()
.then((s) => s.data()?['qrCode'] as String?, onError: (Object _) => null)
: null,
);
}
/// 위치 Quest 의 인증 수단(C-03). 문서에 없으면 GPS 하나다(운영 Quest · 예전 문서).
List<String> _verifyMethodsOf(Map<String, dynamic> quest) {
final raw = quest['verifyMethods'];
final methods = raw is List ? raw.whereType<String>().toList() : <String>[];
return methods.isEmpty ? const ['gps'] : methods;
}
QuestLink _link(Map<String, dynamic> m) => QuestLink(
url: m.strOr('url'),
status: m.strOr('status'),
@@ -476,6 +494,7 @@ class FirestoreQuestRepository implements QuestRepository {
if (draft.pin case final pin?)
'pin': {'lat': pin.lat, 'lng': pin.lng, 'address': pin.name},
if (draft.goal != null) 'goal': draft.goal,
if (draft.extraVerify.isNotEmpty) 'extraVerify': draft.extraVerify,
});
return result.strOr('questId');
}
@@ -489,16 +508,37 @@ class FirestoreQuestRepository implements QuestRepository {
@override
Stream<int> progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream<int>.empty();
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
return _stepCount().asyncMap((cumulative) async {
_walkSteps = await _baseline.todaySteps(cumulative);
return _walkSteps;
});
return _steps();
}
/// Health 걸음 갱신 주기. Health Connect · HealthKit 은 변경을 밀어 주지 않아 다시 묻는다.
static const _healthPoll = Duration(seconds: 15);
/// 오늘 걸음 — Health(0시 KST 부터 합계, B-02)가 1차, 못 쓰면 기기 센서(B-26 「권한 거부 시 앱 자체 집계」).
Stream<int> _steps() async* {
var health = await _todaySteps();
if (health == null) {
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
yield* _stepCount().asyncMap((cumulative) async {
_walkSteps = await _baseline.todaySteps(cumulative);
return _walkSteps;
});
return;
}
while (true) {
_walkSteps = health!;
yield health;
await Future<void>.delayed(_healthPoll);
health = await _todaySteps() ?? health;
}
}
@override
Future<QuestReward> verify(QuestCard card) async {
Future<QuestReward> verify(
QuestCard card, {
PlaceProof proof = const PlaceProof(),
}) async {
// 열린 TIME ATTACK 회차 — 원 Quest 유형과 무관하게 회차 참여로 보낸다(10/3 통합회신 Q-TA-01).
final reason = card.endsAt != null ? null : card.type.blockedReason;
if (reason != null) throw BackendException('E-QST-01', reason);
@@ -511,7 +551,7 @@ class FirestoreQuestRepository implements QuestRepository {
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
QuestType.attend => await _backend.call('fnQuestAttend', {'questId': card.id}),
QuestType.steps => await _verifySteps(card),
QuestType.place => await _verifyPlace(card),
QuestType.place => await _verifyPlace(card, proof),
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
@@ -630,7 +670,7 @@ class FirestoreQuestRepository implements QuestRepository {
});
}
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) async {
Future<Map<String, dynamic>> _verifyPlace(QuestCard card, PlaceProof proof) async {
final quest = await _backend.db
.doc('quests/${card.id}')
.get()
@@ -642,7 +682,23 @@ class FirestoreQuestRepository implements QuestRepository {
throw BackendException('E-QST-03', appL10n.questNoPlace);
}
final fix = await _placeFix();
// 사진은 Storage 에 올리고 경로를 보낸다 — 서버가 접두(본인 · 이 Quest)와 파일 존재를 다시 본다.
String? photoPath;
if (proof.photo case final bytes?) {
photoPath =
'questProofs/${_backend.requireUid}/${card.id}/${DateTime.now().millisecondsSinceEpoch}.jpg';
try {
await _backend.storage
.ref(photoPath)
.putData(bytes, SettableMetadata(contentType: 'image/jpeg'));
} on FirebaseException {
throw BackendException('E-QST-01', appL10n.photoUploadFailed);
}
}
return _backend.call('fnQuestVerifyPlace', {
// 추가 인증 수단(C-03) — GPS 와 함께 보낸다. 판정은 서버가 한다.
if (proof.qr case final qr? when qr.isNotEmpty) 'qr': qr,
'photo': ?photoPath,
// 참여 횟수는 Quest 단위로 센다(정책 원장 C-12 — 같은 장소의 다른 Quest 는 별개).
'questId': card.id,
'placeId': placeId,