걸음 수 Health Connect·HealthKit · 위치 퀘스트 사진·QR 인증 · 행성 형태 확정
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
<!-- 만보기 퀘스트(S-H-WALK). Android 10(API 29)부터 런타임 권한이고 minSdk 가 29 라
|
<!-- 만보기 퀘스트(S-H-WALK). Android 10(API 29)부터 런타임 권한이고 minSdk 가 29 라
|
||||||
분기 없이 항상 요청한다. 거부하면 인증만 막히고 목록·상세는 그대로 열린다. -->
|
분기 없이 항상 요청한다. 거부하면 인증만 막히고 목록·상세는 그대로 열린다. -->
|
||||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||||
|
<!-- 만보기 걸음 수 — Health Connect(견적서 「만보기(Health API)」 · 대조표 B-02). 걸음 읽기 하나만
|
||||||
|
선언한다(Play 건강 앱 선언 양식 — 요청 권한마다 사유를 적는다). 거부하면 위 기기 센서로 센다(B-26). -->
|
||||||
|
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||||
|
|
||||||
<!-- 위치 퀘스트(S-H-PLACE). 별첨 B **B-27** 「탐색은 가능하나 보상 인증은
|
<!-- 위치 퀘스트(S-H-PLACE). 별첨 B **B-27** 「탐색은 가능하나 보상 인증은
|
||||||
GPS/Geofence 필수」 — 상시 추적이 아니라 인증 시점에만 쓴다(v1.6.1 13장).
|
GPS/Geofence 필수」 — 상시 추적이 아니라 인증 시점에만 쓴다(v1.6.1 13장).
|
||||||
@@ -34,7 +37,22 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Health Connect 권한 화면의 「개인정보처리방침」 링크가 여는 화면(health 플러그인 설정 안내). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- Android 14+ 의 Health Connect 권한 사용 내역 → 앱. -->
|
||||||
|
<activity-alias
|
||||||
|
android:name="ViewPermissionUsageActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||||
|
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
<!-- Apple Android 웹 인증 callback. Service ID/redirect URI가 투입되기 전에는
|
<!-- Apple Android 웹 인증 callback. Service ID/redirect URI가 투입되기 전에는
|
||||||
Dart 설정 게이트가 SDK 호출을 막는다. -->
|
Dart 설정 게이트가 SDK 호출을 막는다. -->
|
||||||
<activity
|
<activity
|
||||||
@@ -89,6 +107,11 @@
|
|||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
|
<!-- Health Connect 설치 여부 확인(health 플러그인). -->
|
||||||
|
<package android:name="com.google.android.apps.healthdata" />
|
||||||
|
<intent>
|
||||||
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||||
|
</intent>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package kr.co.fanuzu.app
|
|||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.os.Bundle
|
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 기본 알림 채널을 만든다.
|
* FCM 기본 알림 채널을 만든다.
|
||||||
*
|
*
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 198 KiB |
@@ -522,6 +522,13 @@ service cloud.firestore {
|
|||||||
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
|
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
|
||||||
|
|
||||||
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
|
// 퀘스트 정의·보상 파라미터. 화면: 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} {
|
match /quests/{questId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
|
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||||
import { logger } from 'firebase-functions';
|
import { logger } from 'firebase-functions';
|
||||||
@@ -352,8 +353,13 @@ export interface CreateInput {
|
|||||||
goal?: number | undefined;
|
goal?: number | undefined;
|
||||||
/** 지도에 꽂은 핀(등록 장소가 아닐 때) — Figma `371:356705` 위치 선택. */
|
/** 지도에 꽂은 핀(등록 장소가 아닐 때) — Figma `371:356705` 위치 선택. */
|
||||||
pin?: { lat: number; lng: number; address: string } | undefined;
|
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) => {
|
export const fnQuestCreate = onCall(async (req) => {
|
||||||
const data = (req.data ?? {}) as Record<string, unknown>;
|
const data = (req.data ?? {}) as Record<string, unknown>;
|
||||||
const int = (k: string) => (typeof data[k] === 'number' ? data[k] as number : 0);
|
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'),
|
placeId: optionalStr(data, 'placeId'),
|
||||||
goal: int('goal'),
|
goal: int('goal'),
|
||||||
pin: data.pin && typeof data.pin === 'object' ? data.pin as CreateInput['pin'] : undefined,
|
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).
|
// 1) 예치 + Draft(수수료 미부과 — B-30).
|
||||||
const draft = {
|
const draft = {
|
||||||
type: activity.key, activityKey: activity.key, categoryKey: category.key,
|
type: activity.key, activityKey: activity.key, categoryKey: category.key,
|
||||||
title, userDescription: description, authorUid: uid, authorName: publicNameOf(user),
|
title, userDescription: description, authorUid: uid, authorName: publicNameOf(user),
|
||||||
fandomId, rewardMode, rewardPop, capacity, goal,
|
fandomId, rewardMode, rewardPop, capacity, goal,
|
||||||
link, placeId, verifyMethods: placeId ? ['gps'] : [],
|
link, placeId, verifyMethods: placeId ? ['gps', ...extras] : [],
|
||||||
repeat: { allowed: false, intervalHours: 24, maxCount: 1 },
|
repeat: { allowed: false, intervalHours: 24, maxCount: 1 },
|
||||||
endsAt: Timestamp.fromDate(endsAt), creatorTz: offsetLabel(input.utcOffsetMinutes),
|
endsAt: Timestamp.fromDate(endsAt), creatorTz: offsetLabel(input.utcOffsetMinutes),
|
||||||
// 마감은 검수 승인(실제 공개) 시각으로 다시 계산한다 — 입력값을 남겨 둔다.
|
// 마감은 검수 승인(실제 공개) 시각으로 다시 계산한다 — 입력값을 남겨 둔다.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
|
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 { callerUid, num, optionalStr, str } from '../core/callable.js';
|
||||||
import { ERR, fail, positiveInt } from '../core/errors.js';
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
@@ -197,6 +198,12 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
* (`quests.repeat.allowed`)만 직전 **유효** 인증부터 `intervalHours`(기본 24) 간격으로
|
* (`quests.repeat.allowed`)만 직전 **유효** 인증부터 `intervalHours`(기본 24) 간격으로
|
||||||
* `maxCount` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다).
|
* `maxCount` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다).
|
||||||
*/
|
*/
|
||||||
|
/** 방문 인증 사진이 Storage 에 있는가. 테스트가 바꿔 끼운다. */
|
||||||
|
export const placeProofStorage = {
|
||||||
|
exists: async (path: string): Promise<boolean> => (await getStorage().bucket().file(path).exists())[0],
|
||||||
|
};
|
||||||
|
const proofPhotoExists = (path: string) => placeProofStorage.exists(path).catch(() => false);
|
||||||
|
|
||||||
export const fnQuestVerifyPlace = onCall(async (req) => {
|
export const fnQuestVerifyPlace = onCall(async (req) => {
|
||||||
const uid = callerUid(req);
|
const uid = callerUid(req);
|
||||||
const placeId = str(req.data, 'placeId');
|
const placeId = str(req.data, 'placeId');
|
||||||
@@ -213,9 +220,13 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
|
|||||||
if (questPlace !== placeId) {
|
if (questPlace !== placeId) {
|
||||||
fail(ERR.questOutOfRange, '이 퀘스트의 인증 장소가 아닙니다.');
|
fail(ERR.questOutOfRange, '이 퀘스트의 인증 장소가 아닙니다.');
|
||||||
}
|
}
|
||||||
// 인증 수단은 **선택 입력**이다(C-03).
|
// 추가 인증 수단(C-03) — Quest 가 고른 수단(`verifyMethods` 의 photo · qr)은 GPS 와 **함께** 와야 한다.
|
||||||
const proofKind = optionalStr(req.data, 'proofKind') ?? '';
|
// 고르지 않은 Quest 는 GPS 만으로 인증된다. 예전 앱의 `proofKind` · `proof` 한 쌍도 받는다.
|
||||||
const proof = optionalStr(req.data, 'proof') ?? '';
|
const methods = new Set((questDoc.get('verifyMethods') as unknown[] | undefined) ?? []);
|
||||||
|
const legacyKind = optionalStr(req.data, 'proofKind') ?? '';
|
||||||
|
const legacyProof = optionalStr(req.data, 'proof') ?? '';
|
||||||
|
const qr = optionalStr(req.data, 'qr') ?? (legacyKind === 'qr' ? legacyProof : '');
|
||||||
|
const photo = optionalStr(req.data, 'photo') ?? (legacyKind === 'photo' ? legacyProof : '');
|
||||||
const mocked = (req.data as Record<string, unknown>)?.mocked === true;
|
const mocked = (req.data as Record<string, unknown>)?.mocked === true;
|
||||||
const accuracyM = (req.data as Record<string, unknown>)?.accuracyM;
|
const accuracyM = (req.data as Record<string, unknown>)?.accuracyM;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -233,17 +244,23 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
|
|||||||
|
|
||||||
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
||||||
const inRange = gap <= radius;
|
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
|
const reject = mocked
|
||||||
? ERR.questAbnormal
|
? ERR.questAbnormal
|
||||||
: !inRange
|
: !inRange
|
||||||
? ERR.questOutOfRange
|
? ERR.questOutOfRange
|
||||||
|
: !qrOk || !photoOk
|
||||||
|
? ERR.questIncomplete
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await logRef.set({
|
await logRef.set({
|
||||||
type: proofKind === 'qr' ? 'placeQr' : proofKind === 'photo' ? 'placePhoto' : 'placeGps',
|
type: qr ? 'placeQr' : photo ? 'placePhoto' : 'placeGps',
|
||||||
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
||||||
value: Math.round(gap),
|
value: Math.round(gap),
|
||||||
evidence: {
|
evidence: {
|
||||||
@@ -252,7 +269,10 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
|
|||||||
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
|
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
|
||||||
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
|
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
|
||||||
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
|
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,
|
questId,
|
||||||
@@ -269,6 +289,8 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
|
|||||||
if (!inRange) {
|
if (!inRange) {
|
||||||
fail(ERR.questOutOfRange, '해당 장소에서 너무 멀리 떨어져 있습니다.');
|
fail(ERR.questOutOfRange, '해당 장소에서 너무 멀리 떨어져 있습니다.');
|
||||||
}
|
}
|
||||||
|
if (!qrOk) fail(ERR.questIncomplete, '이 장소의 QR 코드를 스캔해 주세요.');
|
||||||
|
if (!photoOk) fail(ERR.questIncomplete, '방문 인증 사진을 찍어 주세요.');
|
||||||
// C-12 참여 횟수 · 간격. 판정은 진행 문서의 **직전 유효 인증**만 본다.
|
// C-12 참여 횟수 · 간격. 판정은 진행 문서의 **직전 유효 인증**만 본다.
|
||||||
const progressSnap = await db.doc(`users/${uid}/questProgress/${questId}`).get();
|
const progressSnap = await db.doc(`users/${uid}/questProgress/${questId}`).get();
|
||||||
const repeat = (questDoc.get('repeat') ?? {}) as { allowed?: unknown; intervalHours?: unknown; maxCount?: unknown };
|
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}시간 뒤부터 가능합니다.`);
|
fail(ERR.popDuplicate, `다음 인증은 직전 인증 ${intervalHours}시간 뒤부터 가능합니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추가 인증 수단이 왔으면 한 번만 쓴다. 재사용은 같은 방문을 여러 번 인정하는 셈이다.
|
// 사진은 한 번만 쓴다 — 같은 파일을 다시 내는 것은 같은 방문을 여러 번 인정하는 셈이다. QR 은 장소에
|
||||||
if (proofGiven) {
|
// 붙은 같은 코드를 모두가 찍으므로 1회성이 아니다(1인 횟수는 위 C-12 가 막는다).
|
||||||
|
if (methods.has('photo')) {
|
||||||
try {
|
try {
|
||||||
await db.doc(`placeProofs/${proofKind}_${proof}`).create({
|
await db.doc(`placeProofs/photo_${photo.replaceAll('/', '_')}`).create({
|
||||||
uid, placeId, createdAt: FieldValue.serverTimestamp(),
|
uid, placeId, createdAt: FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { grantPop } from '../src/ledger/ledger.js';
|
|||||||
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
|
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
|
||||||
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
|
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
|
||||||
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.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';
|
import { fnCampaignFund } from '../src/fandom/fandom.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +81,7 @@ beforeEach(async () => {
|
|||||||
const snap = await db().collection(`users/${UID}/${sub}`).get();
|
const snap = await db().collection(`users/${UID}/${sub}`).get();
|
||||||
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
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();
|
const docs = await db().collection(c).get();
|
||||||
await Promise.all(docs.docs.map((d) => d.ref.delete()));
|
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);
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('반경 안 + QR 이면 장소 보상값으로 지급하고 인증 재사용을 막는다', async () => {
|
it('QR 을 고른 Quest 는 발급 코드와 같은 QR 이 GPS 와 함께 와야 지급하고 두 판정을 모두 남긴다(C-03)', async () => {
|
||||||
const result = await call(
|
await db().doc('quests/place-quest-qr').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
|
||||||
fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' },
|
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.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();
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
expect(only(logs.docs).get('verified')).toBe(true);
|
expect(only(logs.docs).get('type')).toBe('placeQr');
|
||||||
|
|
||||||
// 같은 QR 토큰을 다른 Quest 에 다시 쓰는 시도.
|
|
||||||
expect(await codeOf(
|
|
||||||
() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-reuse', proofKind: 'qr', proof: 'tok-ok' }),
|
|
||||||
)).toBe('E-POP-03');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,18 @@ describe('위치 선택 지도 핀 (Figma 371:356705)', () => {
|
|||||||
expect((ok as Record<string, unknown>).rewarded).toBe(5);
|
expect((ok as Record<string, unknown>).rewarded).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('추가 인증 수단(사진 · QR)을 고르면 GPS 에 덧붙고, QR 이면 생성자만 읽는 코드를 발급한다(C-03)', async () => {
|
||||||
|
const { questId } = await create({
|
||||||
|
activityKey: 'location', url: undefined, manualTitle: undefined,
|
||||||
|
pin: { lat: 37.4979, lng: 127.0276, address: '서울특별시 강남구 역삼동' },
|
||||||
|
extraVerify: ['qr', 'nfc', 'photo'],
|
||||||
|
});
|
||||||
|
expect((await quest(questId)).verifyMethods).toEqual(['gps', 'photo', 'qr']);
|
||||||
|
const secret = (await db().doc(`questSecrets/${questId}`).get()).data()!;
|
||||||
|
expect(secret.authorUid).toBe(CREATOR);
|
||||||
|
expect(secret.qrCode).toMatch(/^[0-9a-f]{24}$/);
|
||||||
|
});
|
||||||
|
|
||||||
it('좌표가 틀리면 거부한다', async () => {
|
it('좌표가 틀리면 거부한다', async () => {
|
||||||
expect(await codeOf(() => create({
|
expect(await codeOf(() => create({
|
||||||
activityKey: 'location', url: undefined, manualTitle: undefined,
|
activityKey: 'location', url: undefined, manualTitle: undefined,
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ service firebase.storage {
|
|||||||
allow delete: if isSelf(uid);
|
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초)은
|
// 그 밖의 경로는 닫는다. 인사이드 미디어 · 라운지 영상(B-44 영상 100MB/60초)은
|
||||||
// 업로드 동선이 들어오는 회차에 같은 방식으로 상한과 함께 연다.
|
// 업로드 동선이 들어오는 회차에 같은 방식으로 상한과 함께 연다.
|
||||||
match /{allPaths=**} {
|
match /{allPaths=**} {
|
||||||
|
|||||||
@@ -264,6 +264,17 @@ describe('성장 귀속 행 — users/{uid}/growthEvents (HOME 주간 정보 A-3
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('위치 Quest QR 코드 — questSecrets/{questId} (C-03)', () => {
|
||||||
|
test('생성자만 읽고 참여자는 읽지 못하며 누구도 쓰지 못한다', async () => {
|
||||||
|
await seedDocuments(testEnvironment, {
|
||||||
|
'questSecrets/quest-1': { authorUid: 'alice', qrCode: 'secret' },
|
||||||
|
});
|
||||||
|
await assertSucceeds(getDoc(doc(dbFor('alice'), 'questSecrets/quest-1')));
|
||||||
|
await assertFails(getDoc(doc(dbFor('bob'), 'questSecrets/quest-1')));
|
||||||
|
await assertFails(setDoc(doc(dbFor('alice'), 'questSecrets/quest-2'), { authorUid: 'alice', qrCode: 'x' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('가입 목록 — users/{uid}/memberships (C-15)', () => {
|
describe('가입 목록 — users/{uid}/memberships (C-15)', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await seedDocuments(testEnvironment, {
|
await seedDocuments(testEnvironment, {
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ describe('프로필 사진 — users/{uid}/profile', () => {
|
|||||||
await assertSucceeds(deleteObject(ref(storageFor('alice'), path)));
|
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 () => {
|
test('열지 않은 경로는 기본 거부다', async () => {
|
||||||
await assertFails(
|
await assertFails(
|
||||||
uploadBytes(ref(storageFor('alice'), 'posts/post-1/media/1.png'), bytesOf(1024), image),
|
uploadBytes(ref(storageFor('alice'), 'posts/post-1/media/1.png'), bytesOf(1024), image),
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- health (13.3.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- health (from `.symlinks/plugins/health/ios`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
health:
|
||||||
|
:path: ".symlinks/plugins/health/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
|
health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9
|
||||||
|
|
||||||
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
|
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
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 */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
|
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
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 */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
|
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; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
48D05AE8FDC95B4B55F142B5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
48D05AE8FDC95B4B55F142B5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -66,7 +67,6 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
|
||||||
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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 */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
969D2A0A08424A33F3DBC3C4 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
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";
|
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;
|
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 */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -470,8 +488,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = V28G543VK5;
|
DEVELOPMENT_TEAM = V28G543VK5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -654,8 +672,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = V28G543VK5;
|
DEVELOPMENT_TEAM = V28G543VK5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -678,8 +696,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = V28G543VK5;
|
DEVELOPMENT_TEAM = V28G543VK5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -143,6 +143,24 @@
|
|||||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
"version" : "2.4.1"
|
"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
|
"version" : 2
|
||||||
|
|||||||
@@ -143,6 +143,24 @@
|
|||||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
"version" : "2.4.1"
|
"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
|
"version" : 2
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
푸시 알림. 6주차 FCM 연동 대상이며 환경은 배포 시 production 으로 바뀐다.
|
푸시 알림. 6주차 FCM 연동 대상이며 환경은 배포 시 production 으로 바뀐다.
|
||||||
APNs 인증키는 Apple Developer 계정에 종속된다(계정 이전 별첨 C-4).
|
APNs 인증키는 Apple Developer 계정에 종속된다(계정 이전 별첨 C-4).
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
HealthKit — 만보기 걸음 수(견적서 「만보기(Health API)」 · 대조표 B-02). 읽기만 한다.
|
||||||
|
App ID 에도 HealthKit Capability 가 있어야 서명된다(자동 서명이면 빌드 때 등록된다).
|
||||||
|
-->
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array/>
|
||||||
|
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -63,17 +63,12 @@ Color? planetColorOf(String? key) =>
|
|||||||
///
|
///
|
||||||
/// 그림은 Figma 「홈_성장 완료/행성 생성」(`344:214421`) 형태 칸이 쓰는 **행성 형태 시트 이미지
|
/// 그림은 Figma 「홈_성장 완료/행성 생성」(`344:214421`) 형태 칸이 쓰는 **행성 형태 시트 이미지
|
||||||
/// (1536×1024 · 투명 배경 · 2행 5열)** 에서 칸과 같은 형태를 잘라 낸 것이다(2026-10-06 재추출).
|
/// (1536×1024 · 투명 배경 · 2행 5열)** 에서 칸과 같은 형태를 잘라 낸 것이다(2026-10-06 재추출).
|
||||||
/// 칸 1~5 는 시트 1 · 2 · 3 · 4 · 10번째 형태다. 6번째 칸은 Figma 에서 「다음」 버튼에 가려
|
/// 칸 1~6 은 시트 1 · 2 · 3 · 4 · 10 · 8번째 형태다. 6번째 칸(`2154:16037`)은 화면에서 「다음」 버튼에
|
||||||
/// 어떤 형태인지 보이지 않아 자리표시(1번과 같은 그림)를 둔다 — 해석 확인 요청 Q-A14.
|
/// 가려 보이지 않지만, 노드의 이미지 잘라내기 값(시트 x 0.383 · y 0.480)으로 8번째 형태임을 확인했다.
|
||||||
/// 시트 원본이 형태당 약 260px 라 큰 화면에서는 흐릴 수 있다(Admin 등록 이미지로 바뀌면 해소).
|
/// 시트 원본이 형태당 약 260px 라 큰 화면에서는 흐릴 수 있다 — 사본 Figma 에 더 큰 원본이 없다(Q-A14 ②).
|
||||||
String? planetShapeImage(String? planetType) {
|
String? planetShapeImage(String? planetType) {
|
||||||
final n = int.tryParse(
|
final n = RegExp(r'^shape([1-6])$').firstMatch(planetType ?? '')?.group(1);
|
||||||
RegExp(r'^shape([1-6])$').firstMatch(planetType ?? '')?.group(1) ?? '',
|
return n == null ? null : 'assets/figma/planet-shape-$n.png';
|
||||||
);
|
|
||||||
if (n == null) return null;
|
|
||||||
return n <= 5
|
|
||||||
? 'assets/figma/planet-shape-$n.png'
|
|
||||||
: 'assets/figma/planet-shape-placeholder.png';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 정지 그림 — 형태 · 색을 입힌 행성(목록 카드 · MY). 형태가 없으면 [fallback] 그림 그대로다.
|
/// 정지 그림 — 형태 · 색을 입힌 행성(목록 카드 · MY). 형태가 없으면 [fallback] 그림 그대로다.
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ class _HomeBody extends StatelessWidget {
|
|||||||
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
|
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
|
||||||
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
|
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
|
||||||
// 인기 K-INSIDE.
|
// 인기 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?) ...[
|
if (data.weekly case final weekly?) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: _h,
|
padding: _h,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import '../home_screen.dart' show formatPop;
|
|||||||
|
|
||||||
/// HOME 주간 정보 — 발주사 9/9 검토회신 2항 · HOME 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번(A-37).
|
/// HOME 주간 정보 — 발주사 9/9 검토회신 2항 · HOME 정보위계 v1.0 §2 의 2 · 3 · 6 · 7번(A-37).
|
||||||
///
|
///
|
||||||
/// 🟡 **배치는 임시다.** 기준 화면 「REFINE v5A · HOME · Default」 를 아직 실측하지 못해(당사 사본에
|
/// 🟡 **배치는 임시다.** 9/7 회신이 기준으로 든 「REFINE v5A · HOME · Default」 는 계약 시점 사본
|
||||||
/// 없고 원본 파일은 로그인 필요) 정보 위계 v1.0 §7 「프레임 실측과 무관하게 반드시 포함」에 따라
|
/// Figma 에 없다(전 페이지 이름 · 문구 검색, 2026-10-06). 정보 위계 v1.0 §7 「프레임 실측과 무관하게 반드시
|
||||||
/// 값만 먼저 싣는다. 자리 · 모양은 해석 확인 요청 영역A Q-A16 회신 또는 실측 후 맞춘다.
|
/// 포함」에 따라 값만 먼저 싣고, 자리 · 모양은 해석 확인 요청 Q-A16 회신을 따른다.
|
||||||
/// 값의 뜻은 [HomeWeekly] — 모르는 값은 0 으로 채우지 않고 「—」.
|
/// 값의 뜻은 [HomeWeekly] — 모르는 값은 0 으로 채우지 않고 「—」.
|
||||||
class HomeWeeklyCard extends StatelessWidget {
|
class HomeWeeklyCard extends StatelessWidget {
|
||||||
const HomeWeeklyCard({super.key, required this.weekly});
|
const HomeWeeklyCard({super.key, required this.weekly});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
import '../../core/i18n/app_locale.dart';
|
||||||
|
import '../../core/theme/tokens.dart';
|
||||||
|
|
||||||
|
/// 위치 Quest QR 인증 — 현장에 붙은 QR 을 스캔해 그 값을 돌려준다(원장 C-03 · PRD-05 「QR 인증」).
|
||||||
|
///
|
||||||
|
/// 판정은 서버가 한다(생성 때 발급한 코드와 대조). 이 화면은 첫 번째로 읽힌 값으로 닫힐 뿐이다.
|
||||||
|
/// 🟡 Figma 에 스캔 화면이 없어 카메라 전체 화면 + 닫기 버튼만 둔다(해석 확인 요청 영역B Q-B-10).
|
||||||
|
class QrScanScreen extends StatefulWidget {
|
||||||
|
const QrScanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QrScanScreen> createState() => _QrScanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QrScanScreenState extends State<QrScanScreen> {
|
||||||
|
bool _done = false;
|
||||||
|
|
||||||
|
void _onDetect(BarcodeCapture capture) {
|
||||||
|
if (_done) return;
|
||||||
|
final value = capture.barcodes
|
||||||
|
.map((b) => b.rawValue)
|
||||||
|
.whereType<String>()
|
||||||
|
.where((v) => v.isNotEmpty)
|
||||||
|
.firstOrNull;
|
||||||
|
if (value == null) return;
|
||||||
|
_done = true;
|
||||||
|
Navigator.of(context).pop(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: AppColors.onCosmic,
|
||||||
|
title: Text(context.l10n.questScanQr),
|
||||||
|
),
|
||||||
|
body: MobileScanner(
|
||||||
|
onDetect: _onDetect,
|
||||||
|
errorBuilder: (context, error) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpace.s5),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.questScanQrDenied,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppColors.onCosmic),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -304,6 +304,26 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: _pickPlace,
|
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),
|
const SizedBox(height: AppSpace.s3),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -383,6 +403,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 위치 Quest 에 덧붙일 인증 수단(`photo` · `qr`).
|
||||||
|
final _extraVerify = <String>{};
|
||||||
|
|
||||||
Future<void> _pickPlace() async {
|
Future<void> _pickPlace() async {
|
||||||
if (_places.isEmpty) {
|
if (_places.isEmpty) {
|
||||||
final places = await widget.repository.places();
|
final places = await widget.repository.places();
|
||||||
@@ -446,6 +469,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
|||||||
placeId: _needs('place') && !(_place?.isPin ?? true) ? _place?.id : null,
|
placeId: _needs('place') && !(_place?.isPin ?? true) ? _place?.id : null,
|
||||||
pin: _needs('place') && (_place?.isPin ?? false) ? _place : null,
|
pin: _needs('place') && (_place?.isPin ?? false) ? _place : null,
|
||||||
goal: _needs('goal') ? _intOf(_goal) : null,
|
goal: _needs('goal') ? _intOf(_goal) : null,
|
||||||
|
extraVerify: _needs('place') ? _extraVerify.toList() : const [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on BackendException catch (error) {
|
} on BackendException catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
|
/// - 퀘스트 생성 `523:18152`~`523:18188` · Admin 정의 `567:19531`
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:typed_data' show Uint8List;
|
||||||
|
|
||||||
import '../../core/data/backend.dart' show BackendException;
|
import '../../core/data/backend.dart' show BackendException;
|
||||||
import '../../core/monetization/rewarded_ad.dart';
|
import '../../core/monetization/rewarded_ad.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
@@ -432,10 +434,19 @@ class QuestDetail {
|
|||||||
this.reviews = const [],
|
this.reviews = const [],
|
||||||
this.publishState = '',
|
this.publishState = '',
|
||||||
this.moderationReason,
|
this.moderationReason,
|
||||||
|
this.verifyMethods = const [],
|
||||||
|
this.qrCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
final QuestCard card;
|
final QuestCard card;
|
||||||
|
|
||||||
|
/// 위치 Quest 의 인증 수단(`quests.verifyMethods` — `gps` · `photo` · `qr`, 원장 C-03). PRD-05
|
||||||
|
/// 「장소 상세에서 인증 방법 … 확인」. GPS 는 항상, 사진 · QR 은 생성 때 고른 추가 수단이다.
|
||||||
|
final List<String> verifyMethods;
|
||||||
|
|
||||||
|
/// 생성자에게만 — 현장에 붙일 QR 의 값(`questSecrets/{id}.qrCode`). 참여자에게는 null.
|
||||||
|
final String? qrCode;
|
||||||
|
|
||||||
/// 사용자 생성 Quest 의 공개 상태(`quests.status`) — `review`(검수 대기) · `published` ·
|
/// 사용자 생성 Quest 의 공개 상태(`quests.status`) — `review`(검수 대기) · `published` ·
|
||||||
/// `rejected`(운영 반려) · `cancelled` · `ended`. 운영 Quest 는 빈 값.
|
/// `rejected`(운영 반려) · `cancelled` · `ended`. 운영 Quest 는 빈 값.
|
||||||
final String publishState;
|
final String publishState;
|
||||||
@@ -464,6 +475,17 @@ class QuestDetail {
|
|||||||
final List<QuestSubmission> reviews;
|
final List<QuestSubmission> reviews;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 위치 Quest 의 추가 인증 증거(원장 C-03 · PRD-05 「사진 인증 · QR 인증」). GPS 좌표는 저장소가 따로 잰다.
|
||||||
|
class PlaceProof {
|
||||||
|
const PlaceProof({this.qr, this.photo});
|
||||||
|
|
||||||
|
/// 현장 QR 을 스캔한 값.
|
||||||
|
final String? qr;
|
||||||
|
|
||||||
|
/// 방문 인증 사진(JPEG 바이트). 저장소가 Storage 에 올리고 경로를 보낸다.
|
||||||
|
final Uint8List? photo;
|
||||||
|
}
|
||||||
|
|
||||||
/// 목록 탭. 명세 — *전체 / 진행중 / 완료 / 내 퀘스트*.
|
/// 목록 탭. 명세 — *전체 / 진행중 / 완료 / 내 퀘스트*.
|
||||||
enum QuestTab {
|
enum QuestTab {
|
||||||
all,
|
all,
|
||||||
@@ -502,8 +524,12 @@ class QuestDraft {
|
|||||||
this.placeId,
|
this.placeId,
|
||||||
this.pin,
|
this.pin,
|
||||||
this.goal,
|
this.goal,
|
||||||
|
this.extraVerify = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 위치 Quest 의 추가 인증 수단(`photo` · `qr`, 원장 C-03). GPS 는 서버가 항상 넣는다.
|
||||||
|
final List<String> extraVerify;
|
||||||
|
|
||||||
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
|
/// 지도에 꽂은 핀(등록 장소가 아닐 때) — 서버가 그 좌표로 장소를 만든다.
|
||||||
final QuestPlace? pin;
|
final QuestPlace? pin;
|
||||||
|
|
||||||
@@ -597,7 +623,10 @@ abstract interface class QuestRepository {
|
|||||||
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
|
/// **판정도 지급도 서버가 한다.** 앱은 증거(걸음 수·좌표)만 싣고, 실패는
|
||||||
/// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 ·
|
/// 에러 카탈로그 코드로 돌아온다(E-QST-01 미완주 · E-QST-02 비정상 ·
|
||||||
/// E-QST-03 반경 밖 · E-POP-03 중복).
|
/// E-QST-03 반경 밖 · E-POP-03 중복).
|
||||||
Future<QuestReward> verify(QuestCard card);
|
Future<QuestReward> verify(
|
||||||
|
QuestCard card, {
|
||||||
|
PlaceProof proof = const PlaceProof(),
|
||||||
|
});
|
||||||
|
|
||||||
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
|
/// 보상형 광고 한 편을 불러온다. 불러오지 못하면 E-AD-01 이다.
|
||||||
///
|
///
|
||||||
@@ -831,7 +860,10 @@ class SampleQuestRepository implements QuestRepository {
|
|||||||
void recordShake(ShakeJudge run) {}
|
void recordShake(ShakeJudge run) {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<QuestReward> verify(QuestCard card) async {
|
Future<QuestReward> verify(
|
||||||
|
QuestCard card, {
|
||||||
|
PlaceProof proof = const PlaceProof(),
|
||||||
|
}) async {
|
||||||
_claimed.add(card.id);
|
_claimed.add(card.id);
|
||||||
return QuestReward(
|
return QuestReward(
|
||||||
rewardedPop: card.rewardPop ?? 0,
|
rewardedPop: card.rewardPop ?? 0,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'dart:typed_data' show Uint8List;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../core/ui/app_dialog.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_create_screen.dart' show QuestLinkCard;
|
||||||
import 'quest_data.dart';
|
import 'quest_data.dart';
|
||||||
import 'quest_evidence.dart';
|
import 'quest_evidence.dart';
|
||||||
|
import 'qr_scan_screen.dart';
|
||||||
import 'quest_reward_screen.dart';
|
import 'quest_reward_screen.dart';
|
||||||
import 'widgets/quest_card_tile.dart';
|
import 'widgets/quest_card_tile.dart';
|
||||||
import 'widgets/time_attack_countdown.dart';
|
import 'widgets/time_attack_countdown.dart';
|
||||||
@@ -251,6 +256,35 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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).
|
// 참여 전 간격 · 최대 횟수 안내(C-12).
|
||||||
if (card.type == QuestType.place)
|
if (card.type == QuestType.place)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -826,6 +860,30 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// QR 스캔 · 인증 사진 — Quest 가 고른 수단만. 하나라도 취소하면 null.
|
||||||
|
Future<PlaceProof?> _placeProof(List<String> methods) async {
|
||||||
|
String? qr;
|
||||||
|
if (methods.contains('qr')) {
|
||||||
|
qr = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||||
|
MaterialPageRoute(builder: (_) => const QrScanScreen()),
|
||||||
|
);
|
||||||
|
if (qr == null) return null;
|
||||||
|
}
|
||||||
|
Uint8List? photo;
|
||||||
|
if (methods.contains('photo')) {
|
||||||
|
// B-44 이미지 10MB 상한 — 긴 변 1600 · JPEG 80 으로 줄여 올린다.
|
||||||
|
final shot = await ImagePicker().pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
maxWidth: 1600,
|
||||||
|
maxHeight: 1600,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
|
if (shot == null) return null;
|
||||||
|
photo = await shot.readAsBytes();
|
||||||
|
}
|
||||||
|
return PlaceProof(qr: qr, photo: photo);
|
||||||
|
}
|
||||||
|
|
||||||
/// 보상받기.
|
/// 보상받기.
|
||||||
///
|
///
|
||||||
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
|
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
|
||||||
@@ -839,10 +897,17 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
|||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
|
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
|
||||||
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
|
// 별첨 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);
|
setState(() => _busy = true);
|
||||||
final QuestReward reward;
|
final QuestReward reward;
|
||||||
try {
|
try {
|
||||||
reward = await widget.repository.verify(detail.card);
|
reward = await widget.repository.verify(detail.card, proof: proof);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:io' show Platform;
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:health/health.dart';
|
||||||
import 'package:pedometer/pedometer.dart';
|
import 'package:pedometer/pedometer.dart';
|
||||||
import 'package:sensors_plus/sensors_plus.dart';
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -308,6 +310,64 @@ Future<PlaceFix> devicePlaceFix() async {
|
|||||||
/// 권한을 영구 거부한 사용자를 설정 화면으로 보낸다. 화면의 「설정 열기」가 부른다.
|
/// 권한을 영구 거부한 사용자를 설정 화면으로 보낸다. 화면의 「설정 열기」가 부른다.
|
||||||
Future<void> openQuestPermissionSettings() => openAppSettings();
|
Future<void> openQuestPermissionSettings() => openAppSettings();
|
||||||
|
|
||||||
|
/// 오늘(KST 0시~지금) 걸은 수를 내는 함수. 쓸 수 없으면 null — 기기 센서([StepCountProbe])로 대체한다.
|
||||||
|
typedef TodayStepsProbe = Future<int?> Function();
|
||||||
|
|
||||||
|
/// 오늘 0시(KST) — 서버 `dayKey()` 와 같은 날짜 경계.
|
||||||
|
DateTime kstMidnight(DateTime at) {
|
||||||
|
final kst = at.toUtc().add(const Duration(hours: 9));
|
||||||
|
return DateTime.utc(kst.year, kst.month, kst.day).subtract(const Duration(hours: 9));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 권한 확인은 앱이 떠 있는 동안 한 번만 한다. 거부되면 다음 실행에서 다시 묻는다.
|
||||||
|
Future<bool>? _healthAuthorized;
|
||||||
|
Health? _health;
|
||||||
|
|
||||||
|
/// 오늘 걸음 — **Health Connect(Android) · HealthKit(iOS)** 의 그 날 0시(KST)부터 지금까지 합계.
|
||||||
|
///
|
||||||
|
/// 견적서 「만보기(Health API)」 · 별첨 1 ③-1 「Health 권한 동의 → 걸음 집계」 · 정책 원장 **B-26**
|
||||||
|
/// 「권한 거부 시 앱 자체 집계 전환」. 기기 걸음 센서는 앱을 연 뒤부터만 세므로(앱 열기 전 걸음 누락 —
|
||||||
|
/// 대조표 B-02) 이 값이 1차 경로이고, Health 를 쓸 수 없을 때(Health Connect 미설치 · 권한 거부 ·
|
||||||
|
/// HealthKit 미지원)만 null 을 내 센서 경로로 넘긴다. 판정은 여전히 서버가 한다.
|
||||||
|
///
|
||||||
|
/// 🟡 **임시값 — 회신 대기**(해석 확인 요청 영역B Q-B-09): 사용자가 건강 앱에 **직접 입력한 걸음은
|
||||||
|
/// 빼고** 센다(`includeManualEntry: false`). 자료에 정의가 없고, 넣으면 손으로 숫자를 적어 구간을
|
||||||
|
/// 받는다. Android 는 기록 앱이 입력 방식을 남기지 않으면 걸러지지 않는다(플러그인 문서).
|
||||||
|
///
|
||||||
|
/// ponytail: iOS 는 읽기 권한 거부를 알려 주지 않는다(HealthKit 개인정보 정책 — 거부해도 0 이 온다).
|
||||||
|
/// 그래서 iOS 에서 0 이면 센서 경로로 넘긴다. 실제로 0 걸음인 날은 센서도 0 이라 결과가 같다.
|
||||||
|
Future<int?> deviceTodaySteps() async {
|
||||||
|
try {
|
||||||
|
final health = _health ??= Health();
|
||||||
|
final ok = await (_healthAuthorized ??= () async {
|
||||||
|
await health.configure();
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
await health.getHealthConnectSdkStatus() != HealthConnectSdkStatus.sdkAvailable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const types = [HealthDataType.STEPS];
|
||||||
|
const access = [HealthDataAccess.READ];
|
||||||
|
return await health.hasPermissions(types, permissions: access) == true ||
|
||||||
|
await health.requestAuthorization(types, permissions: access);
|
||||||
|
}());
|
||||||
|
if (!ok) {
|
||||||
|
_healthAuthorized = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
final steps = await health.getTotalStepsInInterval(
|
||||||
|
kstMidnight(now),
|
||||||
|
now,
|
||||||
|
includeManualEntry: false,
|
||||||
|
);
|
||||||
|
if (steps == null || (Platform.isIOS && steps == 0)) return null;
|
||||||
|
return steps;
|
||||||
|
} catch (_) {
|
||||||
|
_healthAuthorized = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 오늘 걸은 수 — **하루 단위 기준선**.
|
/// 오늘 걸은 수 — **하루 단위 기준선**.
|
||||||
///
|
///
|
||||||
/// 걸음 센서가 주는 값은 [deviceStepCount] 설명대로 **부팅 이후 누적**이다. 정책 원장
|
/// 걸음 센서가 주는 값은 [deviceStepCount] 설명대로 **부팅 이후 누적**이다. 정책 원장
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_storage/firebase_storage.dart' show SettableMetadata;
|
||||||
|
|
||||||
import '../../core/data/backend.dart';
|
import '../../core/data/backend.dart';
|
||||||
import '../../core/data/server_clock.dart';
|
import '../../core/data/server_clock.dart';
|
||||||
@@ -28,16 +29,19 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
FirestoreQuestRepository(
|
FirestoreQuestRepository(
|
||||||
this._backend, {
|
this._backend, {
|
||||||
StepCountProbe stepCount = deviceStepCount,
|
StepCountProbe stepCount = deviceStepCount,
|
||||||
|
TodayStepsProbe todaySteps = deviceTodaySteps,
|
||||||
PlaceProbe placeFix = devicePlaceFix,
|
PlaceProbe placeFix = devicePlaceFix,
|
||||||
ShakeProbe shakes = deviceShakes,
|
ShakeProbe shakes = deviceShakes,
|
||||||
WalkBaseline? baseline,
|
WalkBaseline? baseline,
|
||||||
}) : _stepCount = stepCount,
|
}) : _stepCount = stepCount,
|
||||||
|
_todaySteps = todaySteps,
|
||||||
_placeFix = placeFix,
|
_placeFix = placeFix,
|
||||||
_shakes = shakes,
|
_shakes = shakes,
|
||||||
_baseline = baseline ?? WalkBaseline();
|
_baseline = baseline ?? WalkBaseline();
|
||||||
|
|
||||||
final Backend _backend;
|
final Backend _backend;
|
||||||
final StepCountProbe _stepCount;
|
final StepCountProbe _stepCount;
|
||||||
|
final TodayStepsProbe _todaySteps;
|
||||||
final PlaceProbe _placeFix;
|
final PlaceProbe _placeFix;
|
||||||
final ShakeProbe _shakes;
|
final ShakeProbe _shakes;
|
||||||
|
|
||||||
@@ -249,9 +253,23 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
moderationReason: quest['moderation'] is Map
|
moderationReason: quest['moderation'] is Map
|
||||||
? (quest['moderation'] as Map)['reason'] as String?
|
? (quest['moderation'] as Map)['reason'] as String?
|
||||||
: null,
|
: null,
|
||||||
|
verifyMethods: _verifyMethodsOf(quest),
|
||||||
|
qrCode: quest.strOr('authorUid') == uid && _verifyMethodsOf(quest).contains('qr')
|
||||||
|
? await db
|
||||||
|
.doc('questSecrets/$questId')
|
||||||
|
.get()
|
||||||
|
.then((s) => s.data()?['qrCode'] as String?, onError: (Object _) => null)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 위치 Quest 의 인증 수단(C-03). 문서에 없으면 GPS 하나다(운영 Quest · 예전 문서).
|
||||||
|
List<String> _verifyMethodsOf(Map<String, dynamic> quest) {
|
||||||
|
final raw = quest['verifyMethods'];
|
||||||
|
final methods = raw is List ? raw.whereType<String>().toList() : <String>[];
|
||||||
|
return methods.isEmpty ? const ['gps'] : methods;
|
||||||
|
}
|
||||||
|
|
||||||
QuestLink _link(Map<String, dynamic> m) => QuestLink(
|
QuestLink _link(Map<String, dynamic> m) => QuestLink(
|
||||||
url: m.strOr('url'),
|
url: m.strOr('url'),
|
||||||
status: m.strOr('status'),
|
status: m.strOr('status'),
|
||||||
@@ -476,6 +494,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
if (draft.pin case final pin?)
|
if (draft.pin case final pin?)
|
||||||
'pin': {'lat': pin.lat, 'lng': pin.lng, 'address': pin.name},
|
'pin': {'lat': pin.lat, 'lng': pin.lng, 'address': pin.name},
|
||||||
if (draft.goal != null) 'goal': draft.goal,
|
if (draft.goal != null) 'goal': draft.goal,
|
||||||
|
if (draft.extraVerify.isNotEmpty) 'extraVerify': draft.extraVerify,
|
||||||
});
|
});
|
||||||
return result.strOr('questId');
|
return result.strOr('questId');
|
||||||
}
|
}
|
||||||
@@ -489,16 +508,37 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<int> progressStream(QuestCard card) {
|
Stream<int> progressStream(QuestCard card) {
|
||||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||||
|
return _steps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health 걸음 갱신 주기. Health Connect · HealthKit 은 변경을 밀어 주지 않아 다시 묻는다.
|
||||||
|
static const _healthPoll = Duration(seconds: 15);
|
||||||
|
|
||||||
|
/// 오늘 걸음 — Health(0시 KST 부터 합계, B-02)가 1차, 못 쓰면 기기 센서(B-26 「권한 거부 시 앱 자체 집계」).
|
||||||
|
Stream<int> _steps() async* {
|
||||||
|
var health = await _todaySteps();
|
||||||
|
if (health == null) {
|
||||||
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
||||||
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
||||||
return _stepCount().asyncMap((cumulative) async {
|
yield* _stepCount().asyncMap((cumulative) async {
|
||||||
_walkSteps = await _baseline.todaySteps(cumulative);
|
_walkSteps = await _baseline.todaySteps(cumulative);
|
||||||
return _walkSteps;
|
return _walkSteps;
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
_walkSteps = health!;
|
||||||
|
yield health;
|
||||||
|
await Future<void>.delayed(_healthPoll);
|
||||||
|
health = await _todaySteps() ?? health;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<QuestReward> verify(QuestCard card) async {
|
Future<QuestReward> verify(
|
||||||
|
QuestCard card, {
|
||||||
|
PlaceProof proof = const PlaceProof(),
|
||||||
|
}) async {
|
||||||
// 열린 TIME ATTACK 회차 — 원 Quest 유형과 무관하게 회차 참여로 보낸다(10/3 통합회신 Q-TA-01).
|
// 열린 TIME ATTACK 회차 — 원 Quest 유형과 무관하게 회차 참여로 보낸다(10/3 통합회신 Q-TA-01).
|
||||||
final reason = card.endsAt != null ? null : card.type.blockedReason;
|
final reason = card.endsAt != null ? null : card.type.blockedReason;
|
||||||
if (reason != null) throw BackendException('E-QST-01', reason);
|
if (reason != null) throw BackendException('E-QST-01', reason);
|
||||||
@@ -511,7 +551,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
|
// 쓰고 앱은 문서 ID 로 읽어, 지급은 되는데 화면은 「시작하기」에 머문다.
|
||||||
QuestType.attend => await _backend.call('fnQuestAttend', {'questId': card.id}),
|
QuestType.attend => await _backend.call('fnQuestAttend', {'questId': card.id}),
|
||||||
QuestType.steps => await _verifySteps(card),
|
QuestType.steps => await _verifySteps(card),
|
||||||
QuestType.place => await _verifyPlace(card),
|
QuestType.place => await _verifyPlace(card, proof),
|
||||||
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
|
// Community 는 서버가 응원글 · 반응을 확인해 즉시 지급한다(C-11). 증거를 싣지 않는다.
|
||||||
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
|
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
|
||||||
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
|
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
|
||||||
@@ -630,7 +670,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) async {
|
Future<Map<String, dynamic>> _verifyPlace(QuestCard card, PlaceProof proof) async {
|
||||||
final quest = await _backend.db
|
final quest = await _backend.db
|
||||||
.doc('quests/${card.id}')
|
.doc('quests/${card.id}')
|
||||||
.get()
|
.get()
|
||||||
@@ -642,7 +682,23 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
throw BackendException('E-QST-03', appL10n.questNoPlace);
|
throw BackendException('E-QST-03', appL10n.questNoPlace);
|
||||||
}
|
}
|
||||||
final fix = await _placeFix();
|
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', {
|
return _backend.call('fnQuestVerifyPlace', {
|
||||||
|
// 추가 인증 수단(C-03) — GPS 와 함께 보낸다. 판정은 서버가 한다.
|
||||||
|
if (proof.qr case final qr? when qr.isNotEmpty) 'qr': qr,
|
||||||
|
'photo': ?photoPath,
|
||||||
// 참여 횟수는 Quest 단위로 센다(정책 원장 C-12 — 같은 장소의 다른 Quest 는 별개).
|
// 참여 횟수는 Quest 단위로 센다(정책 원장 C-12 — 같은 장소의 다른 Quest 는 별개).
|
||||||
'questId': card.id,
|
'questId': card.id,
|
||||||
'placeId': placeId,
|
'placeId': placeId,
|
||||||
|
|||||||
@@ -873,5 +873,13 @@
|
|||||||
"homeWeeklyRankValue": "#{rank}",
|
"homeWeeklyRankValue": "#{rank}",
|
||||||
"homeWeeklyGrowth": "Growth this week",
|
"homeWeeklyGrowth": "Growth this week",
|
||||||
"homeWeeklyMine": "My contribution 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -873,5 +873,13 @@
|
|||||||
"homeWeeklyRankValue": "#{rank}",
|
"homeWeeklyRankValue": "#{rank}",
|
||||||
"homeWeeklyGrowth": "Crecimiento semanal",
|
"homeWeeklyGrowth": "Crecimiento semanal",
|
||||||
"homeWeeklyMine": "Mi aporte 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -873,5 +873,13 @@
|
|||||||
"homeWeeklyRankValue": "{rank}位",
|
"homeWeeklyRankValue": "{rank}位",
|
||||||
"homeWeeklyGrowth": "今週の成長",
|
"homeWeeklyGrowth": "今週の成長",
|
||||||
"homeWeeklyMine": "今週の私の貢献",
|
"homeWeeklyMine": "今週の私の貢献",
|
||||||
"homeWeeklyBalance": "保有POP"
|
"homeWeeklyBalance": "保有POP",
|
||||||
|
"questVerifyMethods": "認証方法",
|
||||||
|
"questVerifyGps": "GPS",
|
||||||
|
"questVerifyPhoto": "写真",
|
||||||
|
"questVerifyQr": "QR",
|
||||||
|
"questExtraVerify": "追加認証(GPSと一緒に)",
|
||||||
|
"questScanQr": "現地のQRをスキャン",
|
||||||
|
"questScanQrDenied": "カメラを開けません。設定でカメラへのアクセスを許可してください。",
|
||||||
|
"questQrForPlace": "現地に掲示する認証QR"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1827,5 +1827,13 @@
|
|||||||
},
|
},
|
||||||
"homeWeeklyGrowth": "이번 주 성장",
|
"homeWeeklyGrowth": "이번 주 성장",
|
||||||
"homeWeeklyMine": "이번 주 내 기여",
|
"homeWeeklyMine": "이번 주 내 기여",
|
||||||
"homeWeeklyBalance": "보유 POP"
|
"homeWeeklyBalance": "보유 POP",
|
||||||
|
"questVerifyMethods": "인증 방법",
|
||||||
|
"questVerifyGps": "GPS",
|
||||||
|
"questVerifyPhoto": "사진",
|
||||||
|
"questVerifyQr": "QR",
|
||||||
|
"questExtraVerify": "추가 인증 (GPS 와 함께)",
|
||||||
|
"questScanQr": "현장 QR 스캔",
|
||||||
|
"questScanQrDenied": "카메라를 열 수 없어요. 설정에서 카메라 권한을 허용해 주세요.",
|
||||||
|
"questQrForPlace": "현장에 붙일 인증 QR"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5359,6 +5359,54 @@ abstract class AppLocalizations {
|
|||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
/// **'보유 POP'**
|
/// **'보유 POP'**
|
||||||
String get homeWeeklyBalance;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -3049,4 +3049,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeWeeklyBalance => 'My POP';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3066,4 +3066,29 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeWeeklyBalance => 'Mis POP';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2944,4 +2944,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeWeeklyBalance => '保有POP';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2948,4 +2948,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeWeeklyBalance => '보유 POP';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2940,4 +2940,28 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeWeeklyBalance => '持有POP';
|
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 => '张贴在现场的认证二维码';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -873,5 +873,13 @@
|
|||||||
"homeWeeklyRankValue": "第{rank}名",
|
"homeWeeklyRankValue": "第{rank}名",
|
||||||
"homeWeeklyGrowth": "本周成长",
|
"homeWeeklyGrowth": "本周成长",
|
||||||
"homeWeeklyMine": "本周我的贡献",
|
"homeWeeklyMine": "本周我的贡献",
|
||||||
"homeWeeklyBalance": "持有POP"
|
"homeWeeklyBalance": "持有POP",
|
||||||
|
"questVerifyMethods": "认证方式",
|
||||||
|
"questVerifyGps": "GPS",
|
||||||
|
"questVerifyPhoto": "照片",
|
||||||
|
"questVerifyQr": "二维码",
|
||||||
|
"questExtraVerify": "附加认证(与GPS一起)",
|
||||||
|
"questScanQr": "扫描现场二维码",
|
||||||
|
"questScanQrDenied": "无法打开相机。请在设置中允许相机权限。",
|
||||||
|
"questQrForPlace": "张贴在现场的认证二维码"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1957,6 +1957,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questEvidenceUrl => _remote['questEvidenceUrl'] ?? _base.questEvidenceUrl;
|
String get questEvidenceUrl => _remote['questEvidenceUrl'] ?? _base.questEvidenceUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questExtraVerify => _remote['questExtraVerify'] ?? _base.questExtraVerify;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String questFreeDailyCap(String cap) => _fill('questFreeDailyCap', {'cap': cap}) ?? _base.questFreeDailyCap(cap);
|
String questFreeDailyCap(String cap) => _fill('questFreeDailyCap', {'cap': cap}) ?? _base.questFreeDailyCap(cap);
|
||||||
|
|
||||||
@@ -2050,6 +2053,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questPreviewHint => _remote['questPreviewHint'] ?? _base.questPreviewHint;
|
String get questPreviewHint => _remote['questPreviewHint'] ?? _base.questPreviewHint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questQrForPlace => _remote['questQrForPlace'] ?? _base.questQrForPlace;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questReject => _remote['questReject'] ?? _base.questReject;
|
String get questReject => _remote['questReject'] ?? _base.questReject;
|
||||||
|
|
||||||
@@ -2107,6 +2113,12 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questRewardReceived => _remote['questRewardReceived'] ?? _base.questRewardReceived;
|
String get questRewardReceived => _remote['questRewardReceived'] ?? _base.questRewardReceived;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questScanQr => _remote['questScanQr'] ?? _base.questScanQr;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get questScanQrDenied => _remote['questScanQrDenied'] ?? _base.questScanQrDenied;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get questStart => _remote['questStart'] ?? _base.questStart;
|
String get questStart => _remote['questStart'] ?? _base.questStart;
|
||||||
|
|
||||||
@@ -2176,6 +2188,18 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get questUnderReview => _remote['questUnderReview'] ?? _base.questUnderReview;
|
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
|
@override
|
||||||
String get rankEmpty => _remote['rankEmpty'] ?? _base.rankEmpty;
|
String get rankEmpty => _remote['rankEmpty'] ?? _base.rankEmpty;
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
cel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -217,6 +225,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
dio:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -655,6 +679,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.3"
|
version: "1.1.3"
|
||||||
|
health:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: health
|
||||||
|
sha256: "2d9e119f3a1d281139f93149b41032b9f80b759960875bc784c5a25dd3c17524"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.3.1"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -956,6 +988,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
mock_exceptions:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1172,6 +1212,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
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:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1561,6 +1617,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.15.0"
|
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:
|
wkt_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ dependencies:
|
|||||||
flutter_map: ^8.3.2
|
flutter_map: ^8.3.2
|
||||||
latlong2: ^0.10.1
|
latlong2: ^0.10.1
|
||||||
geocoding: ^5.0.0
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -532,7 +532,10 @@ class _AdRepo extends SampleQuestRepository {
|
|||||||
var verified = 0;
|
var verified = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<QuestReward> verify(QuestCard card) async {
|
Future<QuestReward> verify(
|
||||||
|
QuestCard card, {
|
||||||
|
PlaceProof proof = const PlaceProof(),
|
||||||
|
}) async {
|
||||||
verified += 1;
|
verified += 1;
|
||||||
if (verifyError case final error?) throw error;
|
if (verifyError case final error?) throw error;
|
||||||
return super.verify(card);
|
return super.verify(card);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:fancake/features/home/home_data.dart';
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
import 'package:fancake/features/home/home_repository_firestore.dart' show kstWeekStart;
|
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';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
/// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3).
|
/// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3).
|
||||||
@@ -50,6 +51,14 @@ void main() {
|
|||||||
expect(none.rank, isNull);
|
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', () {
|
test('이번 주 시작은 KST 월요일 00:00', () {
|
||||||
// 2026-10-07(수) 15:00 KST → 2026-10-05(월) 00:00 KST = 10-04 15:00 UTC.
|
// 2026-10-07(수) 15:00 KST → 2026-10-05(월) 00:00 KST = 10-04 15:00 UTC.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('생성 때 고른 형태가 HOME · GALAXY · 상세 · MY 그림을 정한다 (A-08 · Figma 344:214421)', () {
|
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('shape$i'), 'assets/figma/planet-shape-$i.png');
|
||||||
}
|
}
|
||||||
expect(planetShapeImage('shape6'), 'assets/figma/planet-shape-placeholder.png');
|
|
||||||
expect(planetShapeImage(null), isNull, reason: '성운 · 옛 행성은 화면이 준 그림');
|
expect(planetShapeImage(null), isNull, reason: '성운 · 옛 행성은 화면이 준 그림');
|
||||||
expect(planetShapeImage('shape7'), isNull);
|
expect(planetShapeImage('shape7'), isNull);
|
||||||
// 생성 화면이 보내는 키와 같은 표여야 한다.
|
// 생성 화면이 보내는 키와 같은 표여야 한다.
|
||||||
|
|||||||
@@ -230,6 +230,20 @@ service cloud.firestore {
|
|||||||
expect(backend.calls.single.payload['occurredAt'], isA<String>());
|
expect(backend.calls.single.payload['occurredAt'], isA<String>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Health 가 있으면 그 날 0시부터의 합계를 센서보다 먼저 쓴다 (B-02)', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(
|
||||||
|
backend,
|
||||||
|
todaySteps: () async => 7_300,
|
||||||
|
stepCount: () => Stream.fromIterable([40_000]),
|
||||||
|
);
|
||||||
|
final card = _card(await repo.load(), 'steps');
|
||||||
|
expect(await repo.progressStream(card).first, 7_300);
|
||||||
|
await repo.verify(card);
|
||||||
|
expect(backend.calls.single.payload['steps'], 7_300);
|
||||||
|
});
|
||||||
|
|
||||||
test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async {
|
test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async {
|
||||||
final db = await _seed();
|
final db = await _seed();
|
||||||
// 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다.
|
// 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다.
|
||||||
@@ -300,6 +314,29 @@ service cloud.firestore {
|
|||||||
expect(call.payload['mocked'], isTrue);
|
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 {
|
test('장소가 등록되지 않은 위치 퀘스트는 서버를 부르지 않는다', () async {
|
||||||
final db = await _seed();
|
final db = await _seed();
|
||||||
await db.doc('quests/place').set({
|
await db.doc('quests/place').set({
|
||||||
|
|||||||
Reference in New Issue
Block a user