걸음 수 Health Connect·HealthKit · 위치 퀘스트 사진·QR 인증 · 행성 형태 확정
This commit is contained in:
@@ -620,7 +620,7 @@ class _HomeBody extends StatelessWidget {
|
||||
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
|
||||
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
|
||||
// 인기 K-INSIDE.
|
||||
// HOME 주간 정보(A-37 · 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번) — 🟡 자리 임시(Q-A16).
|
||||
// HOME 주간 정보(A-37 · 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번) — 🟡 자리 임시(REFINE v5A 가 사본에 없음 · Q-A16).
|
||||
if (data.weekly case final weekly?) ...[
|
||||
Padding(
|
||||
padding: _h,
|
||||
|
||||
@@ -7,9 +7,9 @@ import '../home_screen.dart' show formatPop;
|
||||
|
||||
/// HOME 주간 정보 — 발주사 9/9 검토회신 2항 · HOME 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번(A-37).
|
||||
///
|
||||
/// 🟡 **배치는 임시다.** 기준 화면 「REFINE v5A · HOME · Default」 를 아직 실측하지 못해(당사 사본에
|
||||
/// 없고 원본 파일은 로그인 필요) 정보 위계 v1.0 §7 「프레임 실측과 무관하게 반드시 포함」에 따라
|
||||
/// 값만 먼저 싣는다. 자리 · 모양은 해석 확인 요청 영역A Q-A16 회신 또는 실측 후 맞춘다.
|
||||
/// 🟡 **배치는 임시다.** 9/7 회신이 기준으로 든 「REFINE v5A · HOME · Default」 는 계약 시점 사본
|
||||
/// Figma 에 없다(전 페이지 이름 · 문구 검색, 2026-10-06). 정보 위계 v1.0 §7 「프레임 실측과 무관하게 반드시
|
||||
/// 포함」에 따라 값만 먼저 싣고, 자리 · 모양은 해석 확인 요청 Q-A16 회신을 따른다.
|
||||
/// 값의 뜻은 [HomeWeekly] — 모르는 값은 0 으로 채우지 않고 「—」.
|
||||
class HomeWeeklyCard extends StatelessWidget {
|
||||
const HomeWeeklyCard({super.key, required this.weekly});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
|
||||
/// 위치 Quest QR 인증 — 현장에 붙은 QR 을 스캔해 그 값을 돌려준다(원장 C-03 · PRD-05 「QR 인증」).
|
||||
///
|
||||
/// 판정은 서버가 한다(생성 때 발급한 코드와 대조). 이 화면은 첫 번째로 읽힌 값으로 닫힐 뿐이다.
|
||||
/// 🟡 Figma 에 스캔 화면이 없어 카메라 전체 화면 + 닫기 버튼만 둔다(해석 확인 요청 영역B Q-B-10).
|
||||
class QrScanScreen extends StatefulWidget {
|
||||
const QrScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QrScanScreen> createState() => _QrScanScreenState();
|
||||
}
|
||||
|
||||
class _QrScanScreenState extends State<QrScanScreen> {
|
||||
bool _done = false;
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_done) return;
|
||||
final value = capture.barcodes
|
||||
.map((b) => b.rawValue)
|
||||
.whereType<String>()
|
||||
.where((v) => v.isNotEmpty)
|
||||
.firstOrNull;
|
||||
if (value == null) return;
|
||||
_done = true;
|
||||
Navigator.of(context).pop(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
title: Text(context.l10n.questScanQr),
|
||||
),
|
||||
body: MobileScanner(
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Text(
|
||||
context.l10n.questScanQrDenied,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.onCosmic),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -304,6 +304,26 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _pickPlace,
|
||||
),
|
||||
// 추가 인증 수단(원장 C-03 「Location 의 추가 인증 수단」 · PRD-05 「GPS · 사진 · QR 인증」).
|
||||
// GPS 는 항상 들어간다. 🟡 Figma 생성 화면에 이 줄이 없어 배치는 임시다(해석 확인 요청 영역B Q-B-10).
|
||||
Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Wrap(
|
||||
spacing: AppSpace.s2,
|
||||
children: [
|
||||
for (final (key, label) in [
|
||||
('photo', context.l10n.questVerifyPhoto),
|
||||
('qr', context.l10n.questVerifyQr),
|
||||
])
|
||||
FilterChip(
|
||||
label: Text(label),
|
||||
selected: _extraVerify.contains(key),
|
||||
onSelected: (on) => setState(
|
||||
() => on ? _extraVerify.add(key) : _extraVerify.remove(key),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
|
||||
@@ -383,6 +403,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 위치 Quest 에 덧붙일 인증 수단(`photo` · `qr`).
|
||||
final _extraVerify = <String>{};
|
||||
|
||||
Future<void> _pickPlace() async {
|
||||
if (_places.isEmpty) {
|
||||
final places = await widget.repository.places();
|
||||
@@ -446,6 +469,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
placeId: _needs('place') && !(_place?.isPin ?? true) ? _place?.id : null,
|
||||
pin: _needs('place') && (_place?.isPin ?? false) ? _place : null,
|
||||
goal: _needs('goal') ? _intOf(_goal) : null,
|
||||
extraVerify: _needs('place') ? _extraVerify.toList() : const [],
|
||||
),
|
||||
);
|
||||
} on BackendException catch (error) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
|
||||
library;
|
||||
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -432,10 +434,19 @@ class QuestDetail {
|
||||
this.reviews = const [],
|
||||
this.publishState = '',
|
||||
this.moderationReason,
|
||||
this.verifyMethods = const [],
|
||||
this.qrCode,
|
||||
});
|
||||
|
||||
final QuestCard card;
|
||||
|
||||
/// 위치 Quest 의 인증 수단(`quests.verifyMethods` — `gps` · `photo` · `qr`, 원장 C-03). PRD-05
|
||||
/// 「장소 상세에서 인증 방법 … 확인」. GPS 는 항상, 사진 · QR 은 생성 때 고른 추가 수단이다.
|
||||
final List<String> verifyMethods;
|
||||
|
||||
/// 생성자에게만 — 현장에 붙일 QR 의 값(`questSecrets/{id}.qrCode`). 참여자에게는 null.
|
||||
final String? qrCode;
|
||||
|
||||
/// 사용자 생성 Quest 의 공개 상태(`quests.status`) — `review`(검수 대기) · `published` ·
|
||||
/// `rejected`(운영 반려) · `cancelled` · `ended`. 운영 Quest 는 빈 값.
|
||||
final String publishState;
|
||||
@@ -464,6 +475,17 @@ class QuestDetail {
|
||||
final List<QuestSubmission> reviews;
|
||||
}
|
||||
|
||||
/// 위치 Quest 의 추가 인증 증거(원장 C-03 · PRD-05 「사진 인증 · QR 인증」). GPS 좌표는 저장소가 따로 잰다.
|
||||
class PlaceProof {
|
||||
const PlaceProof({this.qr, this.photo});
|
||||
|
||||
/// 현장 QR 을 스캔한 값.
|
||||
final String? qr;
|
||||
|
||||
/// 방문 인증 사진(JPEG 바이트). 저장소가 Storage 에 올리고 경로를 보낸다.
|
||||
final Uint8List? photo;
|
||||
}
|
||||
|
||||
/// 목록 탭. 명세 — *전체 / 진행중 / 완료 / 내 퀘스트*.
|
||||
enum QuestTab {
|
||||
all,
|
||||
@@ -502,8 +524,12 @@ class QuestDraft {
|
||||
this.placeId,
|
||||
this.pin,
|
||||
this.goal,
|
||||
this.extraVerify = const [],
|
||||
});
|
||||
|
||||
/// 위치 Quest 의 추가 인증 수단(`photo` · `qr`, 원장 C-03). GPS 는 서버가 항상 넣는다.
|
||||
final List<String> extraVerify;
|
||||
|
||||
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
|
||||
final QuestPlace? pin;
|
||||
|
||||
@@ -597,7 +623,10 @@ abstract interface class QuestRepository {
|
||||
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
|
||||
/// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 ·
|
||||
/// E-QST-03 반경 밖 · E-POP-03 중복).
|
||||
Future<QuestReward> verify(QuestCard card);
|
||||
Future<QuestReward> verify(
|
||||
QuestCard card, {
|
||||
PlaceProof proof = const PlaceProof(),
|
||||
});
|
||||
|
||||
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
|
||||
///
|
||||
@@ -831,7 +860,10 @@ class SampleQuestRepository implements QuestRepository {
|
||||
void recordShake(ShakeJudge run) {}
|
||||
|
||||
@override
|
||||
Future<QuestReward> verify(QuestCard card) async {
|
||||
Future<QuestReward> verify(
|
||||
QuestCard card, {
|
||||
PlaceProof proof = const PlaceProof(),
|
||||
}) async {
|
||||
_claimed.add(card.id);
|
||||
return QuestReward(
|
||||
rewardedPop: card.rewardPop ?? 0,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
@@ -17,6 +21,7 @@ import 'quest_ad_screens.dart';
|
||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||
import 'quest_data.dart';
|
||||
import 'quest_evidence.dart';
|
||||
import 'qr_scan_screen.dart';
|
||||
import 'quest_reward_screen.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import 'widgets/time_attack_countdown.dart';
|
||||
@@ -251,6 +256,35 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 인증 방법 — PRD-05 MVP 수용 기준 「장소 상세에서 인증 방법 · 보상 · 참여 현황 확인」 · 원장 C-03.
|
||||
if (card.type == QuestType.place) ...[
|
||||
_Row(
|
||||
label: context.l10n.questVerifyMethods,
|
||||
value: [
|
||||
for (final m in detail.verifyMethods)
|
||||
switch (m) {
|
||||
'gps' => context.l10n.questVerifyGps,
|
||||
'photo' => context.l10n.questVerifyPhoto,
|
||||
'qr' => context.l10n.questVerifyQr,
|
||||
_ => m.toUpperCase(),
|
||||
},
|
||||
].join(' · '),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 생성자 — 현장에 붙일 QR(생성 때 서버가 발급). 참여자에게는 값이 오지 않는다.
|
||||
if (detail.qrCode case final code?) ...[
|
||||
Text(context.l10n.questQrForPlace, style: _caption),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: QrImageView(data: code, size: 180),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// 참여 전 간격 · 최대 횟수 안내(C-12).
|
||||
if (card.type == QuestType.place)
|
||||
Padding(
|
||||
@@ -826,6 +860,30 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null.
|
||||
Future<PlaceProof?> _placeProof(List<String> methods) async {
|
||||
String? qr;
|
||||
if (methods.contains('qr')) {
|
||||
qr = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(builder: (_) => const QrScanScreen()),
|
||||
);
|
||||
if (qr == null) return null;
|
||||
}
|
||||
Uint8List? photo;
|
||||
if (methods.contains('photo')) {
|
||||
// B-44 이미지 10MB 상한 — 긴 변 1600 · JPEG 80 으로 줄여 올린다.
|
||||
final shot = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 1600,
|
||||
maxHeight: 1600,
|
||||
imageQuality: 80,
|
||||
);
|
||||
if (shot == null) return null;
|
||||
photo = await shot.readAsBytes();
|
||||
}
|
||||
return PlaceProof(qr: qr, photo: photo);
|
||||
}
|
||||
|
||||
/// 보상받기.
|
||||
///
|
||||
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
|
||||
@@ -839,10 +897,17 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
if (_busy) return;
|
||||
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
|
||||
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
|
||||
// 위치 Quest 의 추가 인증(C-03) — 고른 수단의 증거를 먼저 모은다. 취소하면 아무것도 보내지 않는다.
|
||||
var proof = const PlaceProof();
|
||||
if (detail.card.type == QuestType.place && detail.card.endsAt == null) {
|
||||
final collected = await _placeProof(detail.verifyMethods);
|
||||
if (collected == null || !mounted) return;
|
||||
proof = collected;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
final QuestReward reward;
|
||||
try {
|
||||
reward = await widget.repository.verify(detail.card);
|
||||
reward = await widget.repository.verify(detail.card, proof: proof);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
||||
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';
|
||||
@@ -308,6 +310,64 @@ Future<PlaceFix> devicePlaceFix() async {
|
||||
/// 권한을 영구 거부한 사용자를 설정 화면으로 보낸다. 화면의 「설정 열기」가 부른다.
|
||||
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] 설명대로 **부팅 이후 누적**이다. 정책 원장
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user