diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index 4fc36d3..a7c8133 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt
index 283c7a3..e7b6530 100644
--- a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt
+++ b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt
@@ -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 기본 알림 채널을 만든다.
*
diff --git a/app/assets/figma/planet-shape-6.png b/app/assets/figma/planet-shape-6.png
new file mode 100644
index 0000000..a6f427b
Binary files /dev/null and b/app/assets/figma/planet-shape-6.png differ
diff --git a/app/assets/figma/planet-shape-placeholder.png b/app/assets/figma/planet-shape-placeholder.png
deleted file mode 100644
index 18a1020..0000000
Binary files a/app/assets/figma/planet-shape-placeholder.png and /dev/null differ
diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules
index 2d62b5f..3f48afb 100644
--- a/app/firebase/firestore.rules
+++ b/app/firebase/firestore.rules
@@ -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 「퀘스트·이클립스 파라미터」
diff --git a/app/firebase/functions/src/quest/ugc.ts b/app/firebase/functions/src/quest/ugc.ts
index c8b52d5..1f29c7a 100644
--- a/app/firebase/functions/src/quest/ugc.ts
+++ b/app/firebase/functions/src/quest/ugc.ts
@@ -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;
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),
// 마감은 검수 승인(실제 공개) 시각으로 다시 계산한다 — 입력값을 남겨 둔다.
diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts
index 1f450ce..ac83745 100644
--- a/app/firebase/functions/src/quest/verify.ts
+++ b/app/firebase/functions/src/quest/verify.ts
@@ -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 => (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)?.mocked === true;
const accuracyM = (req.data as Record)?.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
- : null;
+ : !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 {
diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts
index 234383d..9ab0d13 100644
--- a/app/firebase/functions/test/quest_e2e.test.ts
+++ b/app/firebase/functions/test/quest_e2e.test.ts
@@ -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');
});
});
diff --git a/app/firebase/functions/test/quest_ugc.test.ts b/app/firebase/functions/test/quest_ugc.test.ts
index 6adb708..9424d7f 100644
--- a/app/firebase/functions/test/quest_ugc.test.ts
+++ b/app/firebase/functions/test/quest_ugc.test.ts
@@ -541,6 +541,18 @@ describe('위치 선택 지도 핀 (Figma 371:356705)', () => {
expect((ok as Record).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,
diff --git a/app/firebase/storage.rules b/app/firebase/storage.rules
index 66ee68c..381ee0d 100644
--- a/app/firebase/storage.rules
+++ b/app/firebase/storage.rules
@@ -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=**} {
diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs
index de76aa6..e70c18f 100644
--- a/app/firebase/test/tests/firestore.rules.test.mjs
+++ b/app/firebase/test/tests/firestore.rules.test.mjs
@@ -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, {
diff --git a/app/firebase/test/tests/storage.rules.test.mjs b/app/firebase/test/tests/storage.rules.test.mjs
index a0d65ef..3a5dc21 100644
--- a/app/firebase/test/tests/storage.rules.test.mjs
+++ b/app/firebase/test/tests/storage.rules.test.mjs
@@ -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),
diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock
index b0b7b0a..944a927 100644
--- a/app/ios/Podfile.lock
+++ b/app/ios/Podfile.lock
@@ -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
diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj
index 6db6495..3c9e8d1 100644
--- a/app/ios/Runner.xcodeproj/project.pbxproj
+++ b/app/ios/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
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 = ""; };
+ 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
@@ -66,7 +67,6 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
- 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
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 = ""; };
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;
diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index bc0e8f4..7f442b1 100644
--- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -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
diff --git a/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
index bc0e8f4..7f442b1 100644
--- a/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -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
diff --git a/app/ios/Runner/Runner.entitlements b/app/ios/Runner/Runner.entitlements
index 647988c..296bbe1 100644
--- a/app/ios/Runner/Runner.entitlements
+++ b/app/ios/Runner/Runner.entitlements
@@ -19,6 +19,15 @@
푸시 알림. 6주차 FCM 연동 대상이며 환경은 배포 시 production 으로 바뀐다.
APNs 인증키는 Apple Developer 계정에 종속된다(계정 이전 별첨 C-4).
-->
+
+ com.apple.developer.healthkit
+
+ com.apple.developer.healthkit.access
+
+
aps-environment
development
diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart
index 08f4a51..5b99805 100644
--- a/app/lib/core/ui/planet_25d.dart
+++ b/app/lib/core/ui/planet_25d.dart
@@ -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] 그림 그대로다.
diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart
index 7ca6c25..bf35775 100644
--- a/app/lib/features/home/home_screen.dart
+++ b/app/lib/features/home/home_screen.dart
@@ -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,
diff --git a/app/lib/features/home/widgets/home_weekly.dart b/app/lib/features/home/widgets/home_weekly.dart
index 90736cf..f50b205 100644
--- a/app/lib/features/home/widgets/home_weekly.dart
+++ b/app/lib/features/home/widgets/home_weekly.dart
@@ -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});
diff --git a/app/lib/features/quest/qr_scan_screen.dart b/app/lib/features/quest/qr_scan_screen.dart
new file mode 100644
index 0000000..f675135
--- /dev/null
+++ b/app/lib/features/quest/qr_scan_screen.dart
@@ -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 createState() => _QrScanScreenState();
+}
+
+class _QrScanScreenState extends State {
+ bool _done = false;
+
+ void _onDetect(BarcodeCapture capture) {
+ if (_done) return;
+ final value = capture.barcodes
+ .map((b) => b.rawValue)
+ .whereType()
+ .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),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart
index cc27eeb..df15987 100644
--- a/app/lib/features/quest/quest_create_screen.dart
+++ b/app/lib/features/quest/quest_create_screen.dart
@@ -304,6 +304,26 @@ class _QuestCreateScreenState extends State {
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 {
});
}
+ /// 위치 Quest 에 덧붙일 인증 수단(`photo` · `qr`).
+ final _extraVerify = {};
+
Future _pickPlace() async {
if (_places.isEmpty) {
final places = await widget.repository.places();
@@ -446,6 +469,7 @@ class _QuestCreateScreenState extends State {
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) {
diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart
index 62d4dd1..00430db 100644
--- a/app/lib/features/quest/quest_data.dart
+++ b/app/lib/features/quest/quest_data.dart
@@ -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 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 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 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 verify(QuestCard card);
+ Future verify(
+ QuestCard card, {
+ PlaceProof proof = const PlaceProof(),
+ });
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
///
@@ -831,7 +860,10 @@ class SampleQuestRepository implements QuestRepository {
void recordShake(ShakeJudge run) {}
@override
- Future verify(QuestCard card) async {
+ Future verify(
+ QuestCard card, {
+ PlaceProof proof = const PlaceProof(),
+ }) async {
_claimed.add(card.id);
return QuestReward(
rewardedPop: card.rewardPop ?? 0,
diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart
index 29dc30e..99c44bc 100644
--- a/app/lib/features/quest/quest_detail_screen.dart
+++ b/app/lib/features/quest/quest_detail_screen.dart
@@ -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 {
),
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 {
}
}
+ /// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null.
+ Future _placeProof(List methods) async {
+ String? qr;
+ if (methods.contains('qr')) {
+ qr = await Navigator.of(context, rootNavigator: true).push(
+ 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 {
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(() {
diff --git a/app/lib/features/quest/quest_evidence.dart b/app/lib/features/quest/quest_evidence.dart
index b678aae..01be83d 100644
--- a/app/lib/features/quest/quest_evidence.dart
+++ b/app/lib/features/quest/quest_evidence.dart
@@ -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 devicePlaceFix() async {
/// 권한을 영구 거부한 사용자를 설정 화면으로 보낸다. 화면의 「설정 열기」가 부른다.
Future openQuestPermissionSettings() => openAppSettings();
+/// 오늘(KST 0시~지금) 걸은 수를 내는 함수. 쓸 수 없으면 null — 기기 센서([StepCountProbe])로 대체한다.
+typedef TodayStepsProbe = Future 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? _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 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] 설명대로 **부팅 이후 누적**이다. 정책 원장
diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart
index d83c997..088d88b 100644
--- a/app/lib/features/quest/quest_repository_firestore.dart
+++ b/app/lib/features/quest/quest_repository_firestore.dart
@@ -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 _verifyMethodsOf(Map quest) {
+ final raw = quest['verifyMethods'];
+ final methods = raw is List ? raw.whereType().toList() : [];
+ return methods.isEmpty ? const ['gps'] : methods;
+ }
+
QuestLink _link(Map 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 progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream.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 _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.delayed(_healthPoll);
+ health = await _todaySteps() ?? health;
+ }
}
@override
- Future verify(QuestCard card) async {
+ Future 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