걸음 수 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
@@ -2,6 +2,9 @@
<!-- 만보기 퀘스트(S-H-WALK). Android 10(API 29)부터 런타임 권한이고 minSdk 가 29 라
분기 없이 항상 요청한다. 거부하면 인증만 막히고 목록·상세는 그대로 열린다. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- 만보기 걸음 수 — Health Connect(견적서 「만보기(Health API)」 · 대조표 B-02). 걸음 읽기 하나만
선언한다(Play 건강 앱 선언 양식 — 요청 권한마다 사유를 적는다). 거부하면 위 기기 센서로 센다(B-26). -->
<uses-permission android:name="android.permission.health.READ_STEPS" />
<!-- 위치 퀘스트(S-H-PLACE). 별첨 B **B-27** 「탐색은 가능하나 보상 인증은
GPS/Geofence 필수」 — 상시 추적이 아니라 인증 시점에만 쓴다(v1.6.1 13장).
@@ -34,7 +37,22 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Health Connect 권한 화면의 「개인정보처리방침」 링크가 여는 화면(health 플러그인 설정 안내). -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- Android 14+ 의 Health Connect 권한 사용 내역 → 앱. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".MainActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<!-- Apple Android 웹 인증 callback. Service ID/redirect URI가 투입되기 전에는
Dart 설정 게이트가 SDK 호출을 막는다. -->
<activity
@@ -89,6 +107,11 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- Health Connect 설치 여부 확인(health 플러그인). -->
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
@@ -3,9 +3,10 @@ package kr.co.fanuzu.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterActivity() {
// Health Connect 권한 요청(health 플러그인)이 `registerForActivityResult` 를 쓰므로 FragmentActivity 여야 한다.
class MainActivity : FlutterFragmentActivity() {
/**
* FCM 기본 알림 채널을 만든다.
*
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

+7
View File
@@ -522,6 +522,13 @@ service cloud.firestore {
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
// 위치 Quest 의 QR 코드(원장 C-03) — 생성자가 현장에 붙일 QR 을 띄우려고 읽는다. 참여자가 읽으면
// 찍지 않고 값을 보낼 수 있으므로 생성자 · 조회 등급만. `fn-quest-create` 가 발급하고 고치지 않는다.
match /questSecrets/{questId} {
allow read: if signedIn() && (resource.data.authorUid == request.auth.uid || isViewer());
allow write: if false;
}
match /quests/{questId} {
allow read: if signedIn();
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
+19 -1
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
@@ -352,8 +353,13 @@ export interface CreateInput {
goal?: number | undefined;
/** 지도에 꽂은 핀(등록 장소가 아닐 때) — Figma `371:356705` 위치 선택. */
pin?: { lat: number; lng: number; address: string } | undefined;
/** 위치 Quest 의 추가 인증 수단(C-03 「Location 의 추가 인증 수단」) — `photo` · `qr`. GPS 는 항상 들어간다. */
extraVerify?: string[] | undefined;
}
/** 위치 Quest 에 덧붙일 수 있는 인증 수단(C-03 · PRD-05 「GPS · 사진 · QR 인증」). NFC 는 명세 제출 전이라 뺀다. */
export const PLACE_EXTRA_VERIFY = ['photo', 'qr'] as const;
export const fnQuestCreate = onCall(async (req) => {
const data = (req.data ?? {}) as Record<string, unknown>;
const int = (k: string) => (typeof data[k] === 'number' ? data[k] as number : 0);
@@ -372,6 +378,7 @@ export const fnQuestCreate = onCall(async (req) => {
placeId: optionalStr(data, 'placeId'),
goal: int('goal'),
pin: data.pin && typeof data.pin === 'object' ? data.pin as CreateInput['pin'] : undefined,
extraVerify: Array.isArray(data.extraVerify) ? data.extraVerify.filter((v): v is string => typeof v === 'string') : undefined,
});
});
@@ -474,12 +481,23 @@ export async function createQuest(input: CreateInput, now = new Date()): Promise
}
}
// 위치 Quest 의 인증 수단(C-03) — 현장 방문 조건이라 GPS 는 반드시 들어가고, 고른 추가 수단이 붙는다.
const extras = placeId
? PLACE_EXTRA_VERIFY.filter((m) => input.extraVerify?.includes(m))
: [];
if (extras.includes('qr')) {
// QR 코드 발급 — 생성자만 읽는다(현장에 붙일 QR). 참여자가 읽을 수 있으면 찍지 않고 값을 보낼 수 있다.
await db.doc(`questSecrets/${questRef.id}`).set({
authorUid: uid, qrCode: randomBytes(12).toString('hex'), createdAt: FieldValue.serverTimestamp(),
});
}
// 1) 예치 + Draft(수수료 미부과 — B-30).
const draft = {
type: activity.key, activityKey: activity.key, categoryKey: category.key,
title, userDescription: description, authorUid: uid, authorName: publicNameOf(user),
fandomId, rewardMode, rewardPop, capacity, goal,
link, placeId, verifyMethods: placeId ? ['gps'] : [],
link, placeId, verifyMethods: placeId ? ['gps', ...extras] : [],
repeat: { allowed: false, intervalHours: 24, maxCount: 1 },
endsAt: Timestamp.fromDate(endsAt), creatorTz: offsetLabel(input.utcOffsetMinutes),
// 마감은 검수 승인(실제 공개) 시각으로 다시 계산한다 — 입력값을 남겨 둔다.
+33 -10
View File
@@ -1,5 +1,6 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { callerUid, num, optionalStr, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
@@ -197,6 +198,12 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
* (`quests.repeat.allowed`)만 직전 **유효** 인증부터 `intervalHours`(기본 24) 간격으로
* `maxCount` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다).
*/
/** 방문 인증 사진이 Storage 에 있는가. 테스트가 바꿔 끼운다. */
export const placeProofStorage = {
exists: async (path: string): Promise<boolean> => (await getStorage().bucket().file(path).exists())[0],
};
const proofPhotoExists = (path: string) => placeProofStorage.exists(path).catch(() => false);
export const fnQuestVerifyPlace = onCall(async (req) => {
const uid = callerUid(req);
const placeId = str(req.data, 'placeId');
@@ -213,9 +220,13 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
if (questPlace !== placeId) {
fail(ERR.questOutOfRange, '이 퀘스트의 인증 장소가 아닙니다.');
}
// 인증 수단은 **선택 입력**이다(C-03).
const proofKind = optionalStr(req.data, 'proofKind') ?? '';
const proof = optionalStr(req.data, 'proof') ?? '';
// 추가 인증 수단(C-03) — Quest 가 고른 수단(`verifyMethods` 의 photo · qr)은 GPS 와 **함께** 와야 한다.
// 고르지 않은 Quest 는 GPS 만으로 인증된다. 예전 앱의 `proofKind` · `proof` 한 쌍도 받는다.
const methods = new Set((questDoc.get('verifyMethods') as unknown[] | undefined) ?? []);
const legacyKind = optionalStr(req.data, 'proofKind') ?? '';
const legacyProof = optionalStr(req.data, 'proof') ?? '';
const qr = optionalStr(req.data, 'qr') ?? (legacyKind === 'qr' ? legacyProof : '');
const photo = optionalStr(req.data, 'photo') ?? (legacyKind === 'photo' ? legacyProof : '');
const mocked = (req.data as Record<string, unknown>)?.mocked === true;
const accuracyM = (req.data as Record<string, unknown>)?.accuracyM;
const now = new Date();
@@ -233,17 +244,23 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
const inRange = gap <= radius;
const proofGiven = (proofKind === 'photo' || proofKind === 'qr') && proof.length > 0;
// 판정 순서는 **조작 → 반경**이다. 모의 위치는 좌표 자체를 믿을 수 없다는 뜻이라
// QR — 생성 때 발급한 코드(`questSecrets`)와 같아야 한다. 사진 — 본인이 이 Quest 로 올린 파일이어야 한다.
const qrOk = !methods.has('qr')
|| (qr.length > 0 && qr === (await db.doc(`questSecrets/${questId}`).get()).get('qrCode'));
const photoOk = !methods.has('photo')
|| (photo.startsWith(`questProofs/${uid}/${questId}/`) && !photo.includes('..') && await proofPhotoExists(photo));
// 판정 순서는 **조작 → 반경 → 추가 수단**이다. 모의 위치는 좌표 자체를 믿을 수 없다는 뜻이라
// 반경 판정보다 앞선다.
const reject = mocked
? ERR.questAbnormal
: !inRange
? ERR.questOutOfRange
: !qrOk || !photoOk
? ERR.questIncomplete
: null;
await logRef.set({
type: proofKind === 'qr' ? 'placeQr' : proofKind === 'photo' ? 'placePhoto' : 'placeGps',
type: qr ? 'placeQr' : photo ? 'placePhoto' : 'placeGps',
fandomId: optionalStr(req.data, 'fandomId') ?? '',
value: Math.round(gap),
evidence: {
@@ -252,7 +269,10 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
...(proofGiven ? { proof: { kind: proofKind, ref: proof } } : {}),
// C-03 「GPS 와 QR 판정이 모두 기록」 — 추가 수단은 값과 판정을 함께 남긴다.
...(qr ? { qr: { value: qr, ok: qrOk } } : {}),
...(photo ? { photo: { path: photo, ok: photoOk } } : {}),
...(qr || photo ? { proof: { kind: qr ? 'qr' : 'photo', ref: qr || photo } } : {}),
},
},
questId,
@@ -269,6 +289,8 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
if (!inRange) {
fail(ERR.questOutOfRange, '해당 장소에서 너무 멀리 떨어져 있습니다.');
}
if (!qrOk) fail(ERR.questIncomplete, '이 장소의 QR 코드를 스캔해 주세요.');
if (!photoOk) fail(ERR.questIncomplete, '방문 인증 사진을 찍어 주세요.');
// C-12 참여 횟수 · 간격. 판정은 진행 문서의 **직전 유효 인증**만 본다.
const progressSnap = await db.doc(`users/${uid}/questProgress/${questId}`).get();
const repeat = (questDoc.get('repeat') ?? {}) as { allowed?: unknown; intervalHours?: unknown; maxCount?: unknown };
@@ -286,10 +308,11 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
fail(ERR.popDuplicate, `다음 인증은 직전 인증 ${intervalHours}시간 뒤부터 가능합니다.`);
}
// 추가 인증 수단이 왔으면 한 번만 쓴다. 재사용은 같은 방문을 여러 번 인정하는 셈이다.
if (proofGiven) {
// 사진은 한 번만 쓴다 — 같은 파일을 다시 내는 것은 같은 방문을 여러 번 인정하는 셈이다. QR 은 장소에
// 붙은 같은 코드를 모두가 찍으므로 1회성이 아니다(1인 횟수는 위 C-12 가 막는다).
if (methods.has('photo')) {
try {
await db.doc(`placeProofs/${proofKind}_${proof}`).create({
await db.doc(`placeProofs/photo_${photo.replaceAll('/', '_')}`).create({
uid, placeId, createdAt: FieldValue.serverTimestamp(),
});
} catch {
+47 -13
View File
@@ -7,7 +7,7 @@ import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace } from '../src/quest/verify.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace, placeProofStorage } from '../src/quest/verify.js';
import { fnCampaignFund } from '../src/fandom/fandom.js';
/**
@@ -81,7 +81,7 @@ beforeEach(async () => {
const snap = await db().collection(`users/${UID}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
for (const c of ['placeProofs', 'adSsv', 'adRewardLogs']) {
for (const c of ['placeProofs', 'questSecrets', 'adSsv', 'adRewardLogs']) {
const docs = await db().collection(c).get();
await Promise.all(docs.docs.map((d) => d.ref.delete()));
}
@@ -503,20 +503,54 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('반경 안 + QR 이면 장소 보상값으로 지급하고 인증 재사용을 막는다', async () => {
const result = await call(
fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' },
);
it('QR 을 고른 Quest 는 발급 코드와 같은 QR 이 GPS 와 함께 와야 지급하고 두 판정을 모두 남긴다(C-03)', async () => {
await db().doc('quests/place-quest-qr').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
await db().doc('questSecrets/place-quest-qr').set({ authorUid: 'someone', qrCode: 'code-ok' });
const q = { ...inRange, questId: 'place-quest-qr' };
// GPS 만 · 다른 코드 — 미완료.
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...q, qr: 'code-bad' }))).toBe('E-QST-01');
const result = await call(fnQuestVerifyPlace, { ...q, qr: 'code-ok' });
expect(result.rewarded).toBe(100);
expect(result.distanceM).toBeLessThanOrEqual(100);
const ok = (await db().collection(`users/${UID}/activityLogs`).where('verified', '==', true).get()).docs;
const evidence = only(ok).get('evidence') as { place: { gps: unknown; qr: { value: string; ok: boolean } } };
expect(evidence.place.gps).toBeDefined();
expect(evidence.place.qr).toEqual({ value: 'code-ok', ok: true });
// GPS 없이(반경 밖) QR 만으로는 0건.
await db().doc('quests/place-quest-qr2').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
await db().doc('questSecrets/place-quest-qr2').set({ authorUid: 'someone', qrCode: 'code-ok' });
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...farAway, questId: 'place-quest-qr2', qr: 'code-ok' })))
.toBe('E-QST-03');
});
it('사진을 고른 Quest 는 본인이 이 Quest 로 올린 사진이어야 하고 같은 사진은 다시 못 쓴다', async () => {
const real = placeProofStorage.exists;
placeProofStorage.exists = async (path) => path.endsWith('/a.jpg');
try {
for (const id of ['place-quest-photo', 'place-quest-photo2']) {
await db().doc(`quests/${id}`).set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'photo'] });
}
const q = { ...inRange, questId: 'place-quest-photo' };
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...q, photo: `questProofs/other/place-quest-photo/a.jpg` })))
.toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...q, photo: `questProofs/${UID}/place-quest-photo/none.jpg` })))
.toBe('E-QST-01');
const photo = `questProofs/${UID}/place-quest-photo/a.jpg`;
expect((await call(fnQuestVerifyPlace, { ...q, photo })).rewarded).toBe(100);
// 같은 파일 경로는 이 Quest 접두라 다른 Quest 에는 애초에 맞지 않는다.
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-photo2', photo })))
.toBe('E-QST-01');
} finally {
placeProofStorage.exists = real;
}
});
it('추가 수단을 고르지 않은 Quest 에 온 QR 은 기록만 하고 GPS 로 판정한다', async () => {
const result = await call(fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' });
expect(result.rewarded).toBe(100);
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('verified')).toBe(true);
// 같은 QR 토큰을 다른 Quest 에 다시 쓰는 시도.
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-reuse', proofKind: 'qr', proof: 'tok-ok' }),
)).toBe('E-POP-03');
expect(only(logs.docs).get('type')).toBe('placeQr');
});
});
@@ -541,6 +541,18 @@ describe('위치 선택 지도 핀 (Figma 371:356705)', () => {
expect((ok as Record<string, unknown>).rewarded).toBe(5);
});
it('추가 인증 수단(사진 · QR)을 고르면 GPS 에 덧붙고, QR 이면 생성자만 읽는 코드를 발급한다(C-03)', async () => {
const { questId } = await create({
activityKey: 'location', url: undefined, manualTitle: undefined,
pin: { lat: 37.4979, lng: 127.0276, address: '서울특별시 강남구 역삼동' },
extraVerify: ['qr', 'nfc', 'photo'],
});
expect((await quest(questId)).verifyMethods).toEqual(['gps', 'photo', 'qr']);
const secret = (await db().doc(`questSecrets/${questId}`).get()).data()!;
expect(secret.authorUid).toBe(CREATOR);
expect(secret.qrCode).toMatch(/^[0-9a-f]{24}$/);
});
it('좌표가 틀리면 거부한다', async () => {
expect(await codeOf(() => create({
activityKey: 'location', url: undefined, manualTitle: undefined,
+12
View File
@@ -69,6 +69,18 @@ service firebase.storage {
allow delete: if isSelf(uid);
}
// 위치 Quest 방문 인증 사진 — 원장 C-03 · PRD-05 「GPS · 사진 · QR 인증」. `fn-quest-verify-place` 가
// 이 경로 접두(`questProofs/{본인}/{questId}/`)와 파일 존재를 다시 본다. 운영 검토용이라 본인 · 서버만 읽는다.
// 덮어쓰기 · 삭제 금지 — 제출한 증거가 바뀌면 안 된다. 상한은 B-44 이미지 10MB.
match /questProofs/{uid}/{questId}/{fileName} {
allow read: if isSelf(uid);
allow create: if isSelf(uid)
&& resource == null
&& request.resource.size <= 10 * 1024 * 1024
&& request.resource.contentType.matches('image/(jpeg|png|webp)');
allow update, delete: if false;
}
// 그 밖의 경로는 닫는다. 인사이드 미디어 · 라운지 영상(B-44 영상 100MB/60초)은
// 업로드 동선이 들어오는 회차에 같은 방식으로 상한과 함께 연다.
match /{allPaths=**} {
@@ -264,6 +264,17 @@ describe('성장 귀속 행 — users/{uid}/growthEvents (HOME 주간 정보 A-3
});
});
describe('위치 Quest QR 코드 — questSecrets/{questId} (C-03)', () => {
test('생성자만 읽고 참여자는 읽지 못하며 누구도 쓰지 못한다', async () => {
await seedDocuments(testEnvironment, {
'questSecrets/quest-1': { authorUid: 'alice', qrCode: 'secret' },
});
await assertSucceeds(getDoc(doc(dbFor('alice'), 'questSecrets/quest-1')));
await assertFails(getDoc(doc(dbFor('bob'), 'questSecrets/quest-1')));
await assertFails(setDoc(doc(dbFor('alice'), 'questSecrets/quest-2'), { authorUid: 'alice', qrCode: 'x' }));
});
});
describe('가입 목록 — users/{uid}/memberships (C-15)', () => {
beforeEach(async () => {
await seedDocuments(testEnvironment, {
@@ -93,6 +93,18 @@ describe('프로필 사진 — users/{uid}/profile', () => {
await assertSucceeds(deleteObject(ref(storageFor('alice'), path)));
});
test('방문 인증 사진은 본인 경로에 10MB 이하 이미지 한 번만, 지우지 못한다 (C-03)', async () => {
const path = 'questProofs/alice/quest-1/1.jpg';
const jpeg = { contentType: 'image/jpeg' };
await assertSucceeds(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), jpeg));
await assertFails(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), jpeg));
await assertFails(uploadBytes(ref(storageFor('bob'), 'questProofs/alice/quest-1/2.jpg'), bytesOf(1024), jpeg));
await assertFails(
uploadBytes(ref(storageFor('alice'), 'questProofs/alice/quest-1/3.jpg'), bytesOf(10 * 1024 * 1024 + 1), jpeg),
);
await assertFails(deleteObject(ref(storageFor('alice'), path)));
});
test('열지 않은 경로는 기본 거부다', async () => {
await assertFails(
uploadBytes(ref(storageFor('alice'), 'posts/post-1/media/1.png'), bytesOf(1024), image),
+6
View File
@@ -1,15 +1,21 @@
PODS:
- Flutter (1.0.0)
- health (13.3.1):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- health (from `.symlinks/plugins/health/ios`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
health:
:path: ".symlinks/plugins/health/ios"
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
+23 -5
View File
@@ -10,12 +10,12 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
@@ -51,6 +51,7 @@
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
48D05AE8FDC95B4B55F142B5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
@@ -66,7 +67,6 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
CBE157220A7C382F845775F0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -208,6 +208,7 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
969D2A0A08424A33F3DBC3C4 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -346,6 +347,23 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
969D2A0A08424A33F3DBC3C4 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -470,8 +488,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -654,8 +672,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -678,8 +696,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -143,6 +143,24 @@
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "swift-package-manager-google-mobile-ads",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads",
"state" : {
"revision" : "d1fa1f5c1dfe3b43d56c73f4de388ad3316618bc",
"version" : "13.11.0"
}
},
{
"identity" : "swift-package-manager-google-user-messaging-platform",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
"state" : {
"revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b",
"version" : "3.1.0"
}
}
],
"version" : 2
@@ -143,6 +143,24 @@
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "swift-package-manager-google-mobile-ads",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads",
"state" : {
"revision" : "d1fa1f5c1dfe3b43d56c73f4de388ad3316618bc",
"version" : "13.11.0"
}
},
{
"identity" : "swift-package-manager-google-user-messaging-platform",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
"state" : {
"revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b",
"version" : "3.1.0"
}
}
],
"version" : 2
+9
View File
@@ -19,6 +19,15 @@
푸시 알림. 6주차 FCM 연동 대상이며 환경은 배포 시 production 으로 바뀐다.
APNs 인증키는 Apple Developer 계정에 종속된다(계정 이전 별첨 C-4).
-->
<!--
HealthKit — 만보기 걸음 수(견적서 「만보기(Health API)」 · 대조표 B-02). 읽기만 한다.
App ID 에도 HealthKit Capability 가 있어야 서명된다(자동 서명이면 빌드 때 등록된다).
-->
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
<key>aps-environment</key>
<string>development</string>
</dict>
+5 -10
View File
@@ -63,17 +63,12 @@ Color? planetColorOf(String? key) =>
///
/// 그림은 Figma 「홈_성장 완료/행성 생성」(`344:214421`) 형태 칸이 쓰는 **행성 형태 시트 이미지
/// (1536×1024 · 투명 배경 · 2행 5열)** 에서 칸과 같은 형태를 잘라 낸 것이다(2026-10-06 재추출).
/// 칸 1~5 는 시트 1 · 2 · 3 · 4 · 10번째 형태다. 6번째 칸은 Figma 에서 「다음」 버튼에 가려
/// 어떤 형태인지 보이지 않아 자리표시(1번과 같은 그림)를 둔다 — 해석 확인 요청 Q-A14.
/// 시트 원본이 형태당 약 260px 라 큰 화면에서는 흐릴 수 있다(Admin 등록 이미지로 바뀌면 해소).
/// 칸 1~6 은 시트 1 · 2 · 3 · 4 · 10 · 8번째 형태다. 6번째 칸(`2154:16037`)은 화면에서 「다음」 버튼에
/// 가려 보이지 않지만, 노드의 이미지 잘라내기 값(시트 x 0.383 · y 0.480)으로 8번째 형태임을 확인했다.
/// 시트 원본이 형태당 약 260px 라 큰 화면에서는 흐릴 수 있다 — 사본 Figma 에 더 큰 원본이 없다(Q-A14 ②).
String? planetShapeImage(String? planetType) {
final n = int.tryParse(
RegExp(r'^shape([1-6])$').firstMatch(planetType ?? '')?.group(1) ?? '',
);
if (n == null) return null;
return n <= 5
? 'assets/figma/planet-shape-$n.png'
: 'assets/figma/planet-shape-placeholder.png';
final n = RegExp(r'^shape([1-6])$').firstMatch(planetType ?? '')?.group(1);
return n == null ? null : 'assets/figma/planet-shape-$n.png';
}
/// 정지 그림 — 형태 · 색을 입힌 행성(목록 카드 · MY). 형태가 없으면 [fallback] 그림 그대로다.
+1 -1
View File
@@ -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) {
+34 -2
View File
@@ -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();
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 만보)과 같은 것을 세게 된다.
return _stepCount().asyncMap((cumulative) async {
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,
+9 -1
View File
@@ -873,5 +873,13 @@
"homeWeeklyRankValue": "#{rank}",
"homeWeeklyGrowth": "Growth this week",
"homeWeeklyMine": "My contribution this week",
"homeWeeklyBalance": "My POP"
"homeWeeklyBalance": "My POP",
"questVerifyMethods": "Verification",
"questVerifyGps": "GPS",
"questVerifyPhoto": "Photo",
"questVerifyQr": "QR",
"questExtraVerify": "Extra verification (with GPS)",
"questScanQr": "Scan the on-site QR",
"questScanQrDenied": "Can't open the camera. Allow camera access in Settings.",
"questQrForPlace": "Verification QR to post on site"
}
+9 -1
View File
@@ -873,5 +873,13 @@
"homeWeeklyRankValue": "#{rank}",
"homeWeeklyGrowth": "Crecimiento semanal",
"homeWeeklyMine": "Mi aporte semanal",
"homeWeeklyBalance": "Mis POP"
"homeWeeklyBalance": "Mis POP",
"questVerifyMethods": "Verificación",
"questVerifyGps": "GPS",
"questVerifyPhoto": "Foto",
"questVerifyQr": "QR",
"questExtraVerify": "Verificación adicional (con GPS)",
"questScanQr": "Escanea el QR del lugar",
"questScanQrDenied": "No se puede abrir la cámara. Permite el acceso en Ajustes.",
"questQrForPlace": "QR de verificación para colocar en el lugar"
}
+9 -1
View File
@@ -873,5 +873,13 @@
"homeWeeklyRankValue": "{rank}位",
"homeWeeklyGrowth": "今週の成長",
"homeWeeklyMine": "今週の私の貢献",
"homeWeeklyBalance": "保有POP"
"homeWeeklyBalance": "保有POP",
"questVerifyMethods": "認証方法",
"questVerifyGps": "GPS",
"questVerifyPhoto": "写真",
"questVerifyQr": "QR",
"questExtraVerify": "追加認証(GPSと一緒に)",
"questScanQr": "現地のQRをスキャン",
"questScanQrDenied": "カメラを開けません。設定でカメラへのアクセスを許可してください。",
"questQrForPlace": "現地に掲示する認証QR"
}
+9 -1
View File
@@ -1827,5 +1827,13 @@
},
"homeWeeklyGrowth": "이번 주 성장",
"homeWeeklyMine": "이번 주 내 기여",
"homeWeeklyBalance": "보유 POP"
"homeWeeklyBalance": "보유 POP",
"questVerifyMethods": "인증 방법",
"questVerifyGps": "GPS",
"questVerifyPhoto": "사진",
"questVerifyQr": "QR",
"questExtraVerify": "추가 인증 (GPS 와 함께)",
"questScanQr": "현장 QR 스캔",
"questScanQrDenied": "카메라를 열 수 없어요. 설정에서 카메라 권한을 허용해 주세요.",
"questQrForPlace": "현장에 붙일 인증 QR"
}
+48
View File
@@ -5359,6 +5359,54 @@ abstract class AppLocalizations {
/// In ko, this message translates to:
/// **'보유 POP'**
String get homeWeeklyBalance;
/// No description provided for @questVerifyMethods.
///
/// In ko, this message translates to:
/// **'인증 방법'**
String get questVerifyMethods;
/// No description provided for @questVerifyGps.
///
/// In ko, this message translates to:
/// **'GPS'**
String get questVerifyGps;
/// No description provided for @questVerifyPhoto.
///
/// In ko, this message translates to:
/// **'사진'**
String get questVerifyPhoto;
/// No description provided for @questVerifyQr.
///
/// In ko, this message translates to:
/// **'QR'**
String get questVerifyQr;
/// No description provided for @questExtraVerify.
///
/// In ko, this message translates to:
/// **'추가 인증 (GPS 와 함께)'**
String get questExtraVerify;
/// No description provided for @questScanQr.
///
/// In ko, this message translates to:
/// **'현장 QR 스캔'**
String get questScanQr;
/// No description provided for @questScanQrDenied.
///
/// In ko, this message translates to:
/// **'카메라를 열 수 없어요. 설정에서 카메라 권한을 허용해 주세요.'**
String get questScanQrDenied;
/// No description provided for @questQrForPlace.
///
/// In ko, this message translates to:
/// **'현장에 붙일 인증 QR'**
String get questQrForPlace;
}
class _AppLocalizationsDelegate
+25
View File
@@ -3049,4 +3049,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get homeWeeklyBalance => 'My POP';
@override
String get questVerifyMethods => 'Verification';
@override
String get questVerifyGps => 'GPS';
@override
String get questVerifyPhoto => 'Photo';
@override
String get questVerifyQr => 'QR';
@override
String get questExtraVerify => 'Extra verification (with GPS)';
@override
String get questScanQr => 'Scan the on-site QR';
@override
String get questScanQrDenied =>
'Can\'t open the camera. Allow camera access in Settings.';
@override
String get questQrForPlace => 'Verification QR to post on site';
}
+25
View File
@@ -3066,4 +3066,29 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get homeWeeklyBalance => 'Mis POP';
@override
String get questVerifyMethods => 'Verificación';
@override
String get questVerifyGps => 'GPS';
@override
String get questVerifyPhoto => 'Foto';
@override
String get questVerifyQr => 'QR';
@override
String get questExtraVerify => 'Verificación adicional (con GPS)';
@override
String get questScanQr => 'Escanea el QR del lugar';
@override
String get questScanQrDenied =>
'No se puede abrir la cámara. Permite el acceso en Ajustes.';
@override
String get questQrForPlace => 'QR de verificación para colocar en el lugar';
}
+24
View File
@@ -2944,4 +2944,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get homeWeeklyBalance => '保有POP';
@override
String get questVerifyMethods => '認証方法';
@override
String get questVerifyGps => 'GPS';
@override
String get questVerifyPhoto => '写真';
@override
String get questVerifyQr => 'QR';
@override
String get questExtraVerify => '追加認証(GPSと一緒に)';
@override
String get questScanQr => '現地のQRをスキャン';
@override
String get questScanQrDenied => 'カメラを開けません。設定でカメラへのアクセスを許可してください。';
@override
String get questQrForPlace => '現地に掲示する認証QR';
}
+24
View File
@@ -2948,4 +2948,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get homeWeeklyBalance => '보유 POP';
@override
String get questVerifyMethods => '인증 방법';
@override
String get questVerifyGps => 'GPS';
@override
String get questVerifyPhoto => '사진';
@override
String get questVerifyQr => 'QR';
@override
String get questExtraVerify => '추가 인증 (GPS 와 함께)';
@override
String get questScanQr => '현장 QR 스캔';
@override
String get questScanQrDenied => '카메라를 열 수 없어요. 설정에서 카메라 권한을 허용해 주세요.';
@override
String get questQrForPlace => '현장에 붙일 인증 QR';
}
+24
View File
@@ -2940,4 +2940,28 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get homeWeeklyBalance => '持有POP';
@override
String get questVerifyMethods => '认证方式';
@override
String get questVerifyGps => 'GPS';
@override
String get questVerifyPhoto => '照片';
@override
String get questVerifyQr => '二维码';
@override
String get questExtraVerify => '附加认证(与GPS一起)';
@override
String get questScanQr => '扫描现场二维码';
@override
String get questScanQrDenied => '无法打开相机。请在设置中允许相机权限。';
@override
String get questQrForPlace => '张贴在现场的认证二维码';
}
+9 -1
View File
@@ -873,5 +873,13 @@
"homeWeeklyRankValue": "第{rank}名",
"homeWeeklyGrowth": "本周成长",
"homeWeeklyMine": "本周我的贡献",
"homeWeeklyBalance": "持有POP"
"homeWeeklyBalance": "持有POP",
"questVerifyMethods": "认证方式",
"questVerifyGps": "GPS",
"questVerifyPhoto": "照片",
"questVerifyQr": "二维码",
"questExtraVerify": "附加认证(与GPS一起)",
"questScanQr": "扫描现场二维码",
"questScanQrDenied": "无法打开相机。请在设置中允许相机权限。",
"questQrForPlace": "张贴在现场的认证二维码"
}
@@ -1957,6 +1957,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questEvidenceUrl => _remote['questEvidenceUrl'] ?? _base.questEvidenceUrl;
@override
String get questExtraVerify => _remote['questExtraVerify'] ?? _base.questExtraVerify;
@override
String questFreeDailyCap(String cap) => _fill('questFreeDailyCap', {'cap': cap}) ?? _base.questFreeDailyCap(cap);
@@ -2050,6 +2053,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questPreviewHint => _remote['questPreviewHint'] ?? _base.questPreviewHint;
@override
String get questQrForPlace => _remote['questQrForPlace'] ?? _base.questQrForPlace;
@override
String get questReject => _remote['questReject'] ?? _base.questReject;
@@ -2107,6 +2113,12 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questRewardReceived => _remote['questRewardReceived'] ?? _base.questRewardReceived;
@override
String get questScanQr => _remote['questScanQr'] ?? _base.questScanQr;
@override
String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied;
@override
String get questStart => _remote['questStart'] ?? _base.questStart;
@@ -2176,6 +2188,18 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get questUnderReview => _remote['questUnderReview'] ?? _base.questUnderReview;
@override
String get questVerifyGps => _remote['questVerifyGps'] ?? _base.questVerifyGps;
@override
String get questVerifyMethods => _remote['questVerifyMethods'] ?? _base.questVerifyMethods;
@override
String get questVerifyPhoto => _remote['questVerifyPhoto'] ?? _base.questVerifyPhoto;
@override
String get questVerifyQr => _remote['questVerifyQr'] ?? _base.questVerifyQr;
@override
String get rankEmpty => _remote['rankEmpty'] ?? _base.rankEmpty;
+64
View File
@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
carp_serializable:
dependency: transitive
description:
name: carp_serializable
sha256: f039f8ea22e9437aef13fe7e9743c3761c76d401288dcb702eadd273c3e4dcef
url: "https://pub.dev"
source: hosted
version: "2.0.1"
cel:
dependency: transitive
description:
@@ -217,6 +225,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
url: "https://pub.dev"
source: hosted
version: "12.4.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio:
dependency: transitive
description:
@@ -655,6 +679,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.3"
health:
dependency: "direct main"
description:
name: health
sha256: "2d9e119f3a1d281139f93149b41032b9f80b759960875bc784c5a25dd3c17524"
url: "https://pub.dev"
source: hosted
version: "13.3.1"
hooks:
dependency: transitive
description:
@@ -956,6 +988,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0"
url: "https://pub.dev"
source: hosted
version: "7.4.2"
mock_exceptions:
dependency: transitive
description:
@@ -1172,6 +1212,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use:
dependency: transitive
description:
@@ -1561,6 +1617,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
wkt_parser:
dependency: transitive
description:
+9
View File
@@ -123,6 +123,15 @@ dependencies:
flutter_map: ^8.3.2
latlong2: ^0.10.1
geocoding: ^5.0.0
# 만보기 걸음 수 — Health Connect(Android) · HealthKit(iOS) 의 그 날 0시부터 합계(견적서 「만보기(Health
# API)」 · 대조표 B-02). 2주차 선검증리포트 §2 가 채택한 13.3.1 그대로다. 못 쓰면 위 pedometer 로 센다(B-26).
# 최소 OS 하한 영향 없음(선검증 §3.5 에 이미 들어간 값): Android minSdk 26(앱 29) · iOS 14.0(앱 15.0).
health: ^13.3.1
# 위치 Quest QR 인증 — 현장 QR 스캔(원장 C-03 · PRD-05). iOS 는 Apple Vision, Android 는 ML Kit 번들.
# 최소 OS 하한 영향 없음: Android minSdk 23(앱 29) · iOS 12.0(앱 15.0).
mobile_scanner: ^7.4.2
# 생성자 상세의 「현장에 붙일 인증 QR」 그리기. 순수 Dart 라 네이티브 하한이 없다.
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test:
+4 -1
View File
@@ -532,7 +532,10 @@ class _AdRepo extends SampleQuestRepository {
var verified = 0;
@override
Future<QuestReward> verify(QuestCard card) async {
Future<QuestReward> verify(
QuestCard card, {
PlaceProof proof = const PlaceProof(),
}) async {
verified += 1;
if (verifyError case final error?) throw error;
return super.verify(card);
+9
View File
@@ -1,5 +1,6 @@
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_repository_firestore.dart' show kstWeekStart;
import 'package:fancake/features/quest/quest_evidence.dart' show kstMidnight;
import 'package:flutter_test/flutter_test.dart';
/// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3).
@@ -50,6 +51,14 @@ void main() {
expect(none.rank, isNull);
});
test('만보기 Health 집계 시작은 그 날 0시(KST) — 서버 dayKey 와 같은 경계 (B-02)', () {
// 10-07 00:30 KST(= 10-06 15:30 UTC) → 10-07 00:00 KST = 10-06 15:00 UTC.
expect(
kstMidnight(DateTime.parse('2026-10-06T15:30:00Z')),
DateTime.parse('2026-10-06T15:00:00Z'),
);
});
test('이번 주 시작은 KST 월요일 00:00', () {
// 2026-10-07(수) 15:00 KST → 2026-10-05(월) 00:00 KST = 10-04 15:00 UTC.
expect(
+1 -2
View File
@@ -18,10 +18,9 @@ void main() {
});
test('생성 때 고른 형태가 HOME · GALAXY · 상세 · MY 그림을 정한다 (A-08 · Figma 344:214421)', () {
for (var i = 1; i <= 5; i++) {
for (var i = 1; i <= 6; i++) {
expect(planetShapeImage('shape$i'), 'assets/figma/planet-shape-$i.png');
}
expect(planetShapeImage('shape6'), 'assets/figma/planet-shape-placeholder.png');
expect(planetShapeImage(null), isNull, reason: '성운 · 옛 행성은 화면이 준 그림');
expect(planetShapeImage('shape7'), isNull);
// 생성 화면이 보내는 키와 같은 표여야 한다.
+37
View File
@@ -230,6 +230,20 @@ service cloud.firestore {
expect(backend.calls.single.payload['occurredAt'], isA<String>());
});
test('Health 가 있으면 그 날 0시부터의 합계를 센서보다 먼저 쓴다 (B-02)', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
todaySteps: () async => 7_300,
stepCount: () => Stream.fromIterable([40_000]),
);
final card = _card(await repo.load(), 'steps');
expect(await repo.progressStream(card).first, 7_300);
await repo.verify(card);
expect(backend.calls.single.payload['steps'], 7_300);
});
test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async {
final db = await _seed();
// 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다.
@@ -300,6 +314,29 @@ service cloud.firestore {
expect(call.payload['mocked'], isTrue);
});
test('위치 Quest 의 인증 방법을 상세에 싣고 QR 은 GPS 와 함께 보낸다 (C-03 · PRD-05)', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
placeFix: () async =>
const PlaceFix(lat: 37.5, lng: 126.9, accuracyM: 5, mocked: false),
);
// 수단을 적지 않은 운영 Quest 는 GPS 하나.
expect((await repo.detail('place')).verifyMethods, ['gps']);
await db.doc('quests/place').update({'verifyMethods': ['gps', 'qr']});
await db.doc('questSecrets/place').set({'authorUid': 'other', 'qrCode': 'secret'});
final detail = await repo.detail('place');
expect(detail.verifyMethods, ['gps', 'qr']);
expect(detail.qrCode, isNull, reason: '참여자에게는 QR 값을 주지 않는다');
await repo.verify(detail.card, proof: const PlaceProof(qr: 'scanned'));
final call = backend.calls.single;
expect(call.payload['qr'], 'scanned');
expect(call.payload['lat'], 37.5);
expect(call.payload.containsKey('photo'), isFalse);
});
test('장소가 등록되지 않은 위치 퀘스트는 서버를 부르지 않는다', () async {
final db = await _seed();
await db.doc('quests/place').set({