걸음 수 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,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(() {