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> _verifyPlace(QuestCard card) async { + Future> _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, diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 033a66f..fd42886 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index f40f4ec..7d063ed 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index a74fac4..f227c5f 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -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" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index ee6fe93..e4d51e7 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1827,5 +1827,13 @@ }, "homeWeeklyGrowth": "이번 주 성장", "homeWeeklyMine": "이번 주 내 기여", - "homeWeeklyBalance": "보유 POP" + "homeWeeklyBalance": "보유 POP", + "questVerifyMethods": "인증 방법", + "questVerifyGps": "GPS", + "questVerifyPhoto": "사진", + "questVerifyQr": "QR", + "questExtraVerify": "추가 인증 (GPS 와 함께)", + "questScanQr": "현장 QR 스캔", + "questScanQrDenied": "카메라를 열 수 없어요. 설정에서 카메라 권한을 허용해 주세요.", + "questQrForPlace": "현장에 붙일 인증 QR" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 696d052..fb2600f 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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 diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index f213c50..3381d38 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index d8e6748..5004711 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index a3cf449..759be99 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 047365e..fac5751 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index c1d2e80..66d804a 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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 => '张贴在现场的认证二维码'; } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 45fe622..62524f7 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -873,5 +873,13 @@ "homeWeeklyRankValue": "第{rank}名", "homeWeeklyGrowth": "本周成长", "homeWeeklyMine": "本周我的贡献", - "homeWeeklyBalance": "持有POP" + "homeWeeklyBalance": "持有POP", + "questVerifyMethods": "认证方式", + "questVerifyGps": "GPS", + "questVerifyPhoto": "照片", + "questVerifyQr": "二维码", + "questExtraVerify": "附加认证(与GPS一起)", + "questScanQr": "扫描现场二维码", + "questScanQrDenied": "无法打开相机。请在设置中允许相机权限。", + "questQrForPlace": "张贴在现场的认证二维码" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 3fea589..18c2aaa 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/pubspec.lock b/app/pubspec.lock index b420932..b9bc053 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 480a095..e904878 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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: diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart index 550183f..f70b6db 100644 --- a/app/test/f4_screens_test.dart +++ b/app/test/f4_screens_test.dart @@ -532,7 +532,10 @@ class _AdRepo extends SampleQuestRepository { var verified = 0; @override - Future verify(QuestCard card) async { + Future verify( + QuestCard card, { + PlaceProof proof = const PlaceProof(), + }) async { verified += 1; if (verifyError case final error?) throw error; return super.verify(card); diff --git a/app/test/home_weekly_test.dart b/app/test/home_weekly_test.dart index 162a35e..b0bfcd4 100644 --- a/app/test/home_weekly_test.dart +++ b/app/test/home_weekly_test.dart @@ -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( diff --git a/app/test/planet_fx_test.dart b/app/test/planet_fx_test.dart index ff1b340..bce49c7 100644 --- a/app/test/planet_fx_test.dart +++ b/app/test/planet_fx_test.dart @@ -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); // 생성 화면이 보내는 키와 같은 표여야 한다. diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart index 5bac9b3..26bdad2 100644 --- a/app/test/quest_repository_test.dart +++ b/app/test/quest_repository_test.dart @@ -230,6 +230,20 @@ service cloud.firestore { expect(backend.calls.single.payload['occurredAt'], isA()); }); + 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({