가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:25 +09:00
parent e7010010bb
commit 55424bd4a2
36 changed files with 2884 additions and 77 deletions
+108 -9
View File
@@ -19,13 +19,19 @@ import { ERR, fail } from '../core/errors.js';
*/ */
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
const db = getFirestore(); const db = getFirestore();
const batch = db.batch(); const userRef = db.doc(`users/${user.uid}`);
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
batch.set(db.doc(`users/${user.uid}`), { // 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
const defaults: Record<string, unknown> = {
uid: user.uid, uid: user.uid,
displayName: user.displayName ?? '팬우주 여행자', displayName: user.displayName ?? '팬우주 여행자',
photoUrl: user.photoURL ?? null, photoUrl: user.photoURL ?? null,
countryCode: 'KR', // 국가는 정하지 않는다 — 온보딩 프로필 단계가 기기 지역을 기본값으로 받아 쓴다
// (해석 확인 Q-OB-02 A 「프로필 = 닉네임 · 국가 · 표시 언어」). 비어 있으면 국가 랭킹은 `ZZ`.
email,
locale: 'ko', locale: 'ko',
fandomId: '', fandomId: '',
authProviders: user.providerData.map((p) => p.providerId), authProviders: user.providerData.map((p) => p.providerId),
@@ -35,18 +41,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
notificationPrefs: {}, notificationPrefs: {},
createdAt: FieldValue.serverTimestamp(), createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true }); };
const wallet: Record<string, unknown> = {
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
activityBalance: 0, activityBalance: 0,
supportBalance: 0, supportBalance: 0,
pendingActivity: 0, pendingActivity: 0,
reconcileStatus: 'ok', reconcileStatus: 'ok',
createdAt: FieldValue.serverTimestamp(), createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true }); };
await batch.commit(); // 트리거가 늦게 돌면 그 사이 앱이 쓴 값(약관 동의 · 닉네임 · 국가 · 이메일)이 이미 있다.
// 없는 필드만 채운다 — 덮어쓰면 온보딩 입력이 기본값으로 되돌아간다.
await db.runTransaction(async (tx) => {
const [u, w] = await Promise.all([tx.get(userRef), tx.get(walletRef)]);
const missing = (base: Record<string, unknown>, have: Record<string, unknown> | undefined) =>
Object.fromEntries(Object.entries(base).filter(([k]) => have?.[k] == null));
tx.set(userRef, missing(defaults, u.data()), { merge: true });
tx.set(walletRef, missing(wallet, w.data()), { merge: true });
});
}); });
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */ /** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
@@ -434,6 +447,92 @@ export const fnConsentRecord = onCall(async (req) => {
return { consentId }; return { consentId };
}); });
/**
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
*/
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
/**
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
*
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
*/
export const fnSignupConsent = onCall(async (req) => {
const uid = callerUid(req);
const version = str(req.data, 'version');
const raw = (req.data as Record<string, unknown>)?.agreed;
const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []);
if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) {
throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.');
}
const marketing = agreed.has('marketing');
const db = getFirestore();
const batch = db.batch();
const meta = {
docVersion: version,
ip: req.rawRequest?.ip ?? null,
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
createdAt: FieldValue.serverTimestamp(),
};
for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) {
const granted = docType === 'marketing' ? marketing : true;
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
{ docType, granted, ...meta }, { merge: true });
}
batch.set(db.doc(`users/${uid}`), {
termsAgreedAt: FieldValue.serverTimestamp(),
termsVersion: version,
notificationPrefs: { marketing },
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
return { termsVersion: version, marketing };
});
/**
* `fn-account-withdraw` — 서비스 탈퇴. Figma `554:22903` · `554:24885` · `554:24908`.
*
* - SNS 연동이 남아 있으면(로그인 수단 2개 이상) 「비정상 탈퇴」로 거부한다 — 「연동된 계정 해제 후
* 계정 탈퇴를 진행해주세요.」(`374:14699`).
* - 원장성 데이터(POP 원장 · 결제 · 정산 · 기여)와 작성한 게시물은 지우지 않는다(Figma 「게시물은 삭제되지
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
*/
export const fnAccountWithdraw = onCall(async (req) => {
const uid = callerUid(req);
const auth = getAuth();
const user = await auth.getUser(uid);
if (user.providerData.length > 1) {
fail(ERR.withdrawLinked, '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.');
}
const db = getFirestore();
const userRef = db.doc(`users/${uid}`);
const code = (await userRef.get()).get('inviteCode');
const batch = db.batch();
batch.set(userRef, {
status: 'withdrawn',
withdrawnAt: FieldValue.serverTimestamp(),
nickname: null,
displayName: null,
email: null,
photoUrl: null,
fcmTokens: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
// 탈퇴한 사람의 추천 코드로 새 귀속이 생기지 않게 닫는다.
if (typeof code === 'string' && code) batch.set(db.doc(`inviteCodes/${code}`), { active: false }, { merge: true });
await batch.commit();
await auth.deleteUser(uid);
return { withdrawn: true };
});
/** /**
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다. * `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
* *
+11 -1
View File
@@ -1,4 +1,5 @@
import { getAuth } from 'firebase-admin/auth'; import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { defineString } from 'firebase-functions/params'; import { defineString } from 'firebase-functions/params';
import { onCall } from 'firebase-functions/v2/https'; import { onCall } from 'firebase-functions/v2/https';
@@ -96,7 +97,11 @@ function linkInfo(p: KakaoProfile) {
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> { export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
const existing = await ownerOf(p.id); const existing = await ownerOf(p.id);
if (existing) return existing; if (existing) return existing;
const uid = kakaoUidOf(p.id); // 탈퇴한 계정의 문서(원장 보존)가 `kakao:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
const base = kakaoUidOf(p.id);
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
const uid = withdrawn ? `${base}:${Date.now()}` : base;
const auth = getAuth(); const auth = getAuth();
try { try {
await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) }); await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) });
@@ -139,5 +144,10 @@ export const fnAuthKakao = onCall(async (req) => {
return { linked: true }; return { linked: true };
} }
const uid = await resolveKakaoUid(profile); const uid = await resolveKakaoUid(profile);
// 동의한 이메일만 사용자 문서에 남긴다(10/5). Auth 이메일은 다른 제공자 계정과 겹치면 생성이
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
if (profile.email) {
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
}
return { token: await getAuth().createCustomToken(uid) }; return { token: await getAuth().createCustomToken(uid) };
}); });
+7 -11
View File
@@ -9,7 +9,6 @@ import { callerUid, optionalStr, str } from '../core/callable.js';
import { loadPolicy } from '../core/config.js'; import { loadPolicy } from '../core/config.js';
import { ERR, fail } from '../core/errors.js'; import { ERR, fail } from '../core/errors.js';
import { deviceHashOf } from '../core/ids.js'; import { deviceHashOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.js'; import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.js';
import { fandomOfUser, writeFpiEvent } from '../ledger/fpi.js'; import { fandomOfUser, writeFpiEvent } from '../ledger/fpi.js';
@@ -75,7 +74,7 @@ export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
* 잠금 문서 `referralsByUser/{uid}.reward` — 피추천인 1명의 보상 상태. * 잠금 문서 `referralsByUser/{uid}.reward` — 피추천인 1명의 보상 상태.
* - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기 * - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기
* - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음 * - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음
* - `capped`: 추천인의 하루 상한(`referralDailyCapPerReferrer`) 초과 — 지급 없음 * - `capped`: (폐지) 추천인 하루 상한 초과 — 10/3 통합회신 7항으로 상한 삭제. 이전 기록 해석용으로만 남긴다
* - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정 * - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정
* - `paid`: 확정 · FPI REFERRAL 1 단위 * - `paid`: 확정 · FPI REFERRAL 1 단위
* - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제 * - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제
@@ -110,18 +109,19 @@ export const fnReferralAttribute = onCall(async (req) => {
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다. // 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.'); fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
} }
const { referralAttachDays, referralDailyCapPerReferrer } = await loadPolicy(); // 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(원장 C-120).
// 자기 · 상호 · 중복 · 이상 패턴은 아래 판정과 Risk Engine 이 맡는다.
const { referralAttachDays } = await loadPolicy();
const attributionRef = db.doc(`referrals/${uid}_${code}`); const attributionRef = db.doc(`referrals/${uid}_${code}`);
const lockRef = db.doc(`referralsByUser/${uid}`); const lockRef = db.doc(`referralsByUser/${uid}`);
const capRef = db.doc(`referralDaily/${referrerUid}_${dayKey(new Date())}`);
let reward: ReferralReward = 'pending'; let reward: ReferralReward = 'pending';
try { try {
await db.runTransaction(async (tx) => { await db.runTransaction(async (tx) => {
// 동시 호출의 둘째는 잠금 문서 `create` 충돌(ALREADY_EXISTS)로 떨어진다. // 동시 호출의 둘째는 잠금 문서 `create` 충돌(ALREADY_EXISTS)로 떨어진다.
const [existing, lock, user, reverse, cap, done] = await Promise.all([ const [existing, lock, user, reverse, done] = await Promise.all([
tx.get(attributionRef), tx.get(lockRef), tx.get(db.doc(`users/${uid}`)), tx.get(attributionRef), tx.get(lockRef), tx.get(db.doc(`users/${uid}`)),
tx.get(db.doc(`referralsByUser/${referrerUid}`)), tx.get(capRef), tx.get(db.doc(`referralsByUser/${referrerUid}`)),
// 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다. // 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다.
tx.get(db.collection(`users/${uid}/verifiedCompletions`).limit(1)), tx.get(db.collection(`users/${uid}/verifiedCompletions`).limit(1)),
]); ]);
@@ -132,11 +132,7 @@ export const fnReferralAttribute = onCall(async (req) => {
Date.now() - joined.toMillis() > referralAttachDays * 86_400_000) { Date.now() - joined.toMillis() > referralAttachDays * 86_400_000) {
fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`); fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`);
} }
const today = (cap.get('count') as number | undefined) ?? 0; reward = done.empty ? 'pending' : 'ineligible';
reward = !done.empty ? 'ineligible' : today >= referralDailyCapPerReferrer ? 'capped' : 'pending';
if (reward === 'pending') {
tx.set(capRef, { count: today + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true });
}
tx.create(lockRef, { tx.create(lockRef, {
attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(), attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(),
}); });
@@ -114,8 +114,6 @@ export interface PolicyConfig {
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */ /** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
referralReferrerPop: number; referralReferrerPop: number;
referralReferredPop: number; referralReferredPop: number;
/** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */
referralDailyCapPerReferrer: number;
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */ /** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
referralAttachDays: number; referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */ /** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
@@ -223,7 +221,6 @@ export const DEFAULT_POLICY: PolicyConfig = {
liveWindowMinutes: 5, liveWindowMinutes: 5,
referralReferrerPop: 100, referralReferrerPop: 100,
referralReferredPop: 20, referralReferredPop: 20,
referralDailyCapPerReferrer: 5,
referralAttachDays: 7, referralAttachDays: 7,
timeAttackMaxMinutes: 1440, timeAttackMaxMinutes: 1440,
// 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명). // 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명).
@@ -40,6 +40,8 @@ export const ERR = {
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다. // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
authCredentialInUse: 'E-AUTH-08', authCredentialInUse: 'E-AUTH-08',
authAlreadyLinked: 'E-AUTH-09', authAlreadyLinked: 'E-AUTH-09',
// 서비스 탈퇴 — SNS 연동이 남아 있으면 「비정상 탈퇴」(Figma `554:24885` · `554:24908`).
withdrawLinked: 'E-AUTH-14',
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다. // 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
trnSelfMark: 'E-TRN-01', trnSelfMark: 'E-TRN-01',
trnDailyCap: 'E-TRN-02', trnDailyCap: 'E-TRN-02',
@@ -84,6 +86,7 @@ const STATUS: Record<ErrorCode, Status> = {
'E-AUTH-03': 'permission-denied', 'E-AUTH-03': 'permission-denied',
'E-AUTH-08': 'already-exists', 'E-AUTH-08': 'already-exists',
'E-AUTH-09': 'already-exists', 'E-AUTH-09': 'already-exists',
'E-AUTH-14': 'failed-precondition',
'E-TRN-01': 'permission-denied', 'E-TRN-01': 'permission-denied',
'E-TRN-02': 'resource-exhausted', 'E-TRN-02': 'resource-exhausted',
'E-TRN-03': 'not-found', 'E-TRN-03': 'not-found',
+1 -1
View File
@@ -11,7 +11,7 @@ export {
} from './quest/quest.js'; } from './quest/quest.js';
export { export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate, fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole, fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnSignupConsent, fnAccountWithdraw, fnAdminSetRole,
} from './account/account.js'; } from './account/account.js';
export { fnAuthKakao } from './account/kakao.js'; export { fnAuthKakao } from './account/kakao.js';
export { export {
@@ -0,0 +1,127 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.js';
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
/** 가입 약관 기록(E-04 · E-05) · 계정 생성 트리거의 덮어쓰기 금지 · 국가 미고정(E-13) · 서비스 탈퇴(E-42) · 카카오 이메일(E-02). */
const U = 'test-onb-user';
const KAKAO_ID = '555000111';
const db = () => getFirestore();
type CallReq = Parameters<typeof fnSignupConsent.run>[0];
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) {
const err = e as { code?: string; details?: { code?: string } };
return err.details?.code ?? err.code;
}
};
const onCreate = (uid: string, extra: Record<string, unknown> = {}) =>
(fnAuthOnCreate as unknown as { run: (u: unknown) => Promise<void> })
.run({ uid, email: undefined, displayName: undefined, photoURL: undefined, providerData: [], ...extra });
function stubKakao(email?: string) {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url.endsWith('/v1/user/access_token_info')) return Response.json({ id: Number(KAKAO_ID), app_id: 123 });
return Response.json({
id: Number(KAKAO_ID),
kakao_account: { profile: { nickname: '카카오' }, ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}) },
});
}));
}
async function wipe() {
await getAuth().deleteUsers([U, kakaoUidOf(KAKAO_ID)]);
for (const uid of [U, kakaoUidOf(KAKAO_ID)]) {
await db().recursiveDelete(db().doc(`users/${uid}`));
await wipeUser(uid);
}
}
beforeAll(() => {
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다.');
process.env.KAKAO_APP_ID = '123';
bootAdmin();
});
afterAll(async () => { await wipe(); await shutdownAdmin(); });
beforeEach(wipe);
afterEach(() => { vi.unstubAllGlobals(); });
describe('계정 생성 트리거', () => {
it('국가를 KR 로 고정하지 않고 · 이미 쓴 값(약관 · 닉네임)을 덮지 않는다', async () => {
await db().doc(`users/${U}`).set({ nickname: '먼저씀', termsVersion: 'v1' });
await onCreate(U, { providerData: [{ providerId: 'oidc.line', email: 'line@fan.io' }] });
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.countryCode).toBeUndefined();
expect(user.nickname).toBe('먼저씀');
expect(user.termsVersion).toBe('v1');
expect(user.email).toBe('line@fan.io');
expect(user.status).toBe('active');
expect((await db().doc(`users/${U}/wallet/main`).get()).get('activityBalance')).toBe(0);
});
});
describe('fnSignupConsent', () => {
it('필수 하나라도 빠지면 거부', async () => {
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'location', 'age'] }));
expect(await codeOf(r)).toBe('invalid-argument');
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
});
it('항목별 행 · termsAgreedAt · 마케팅 = Push Marketing', async () => {
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'location', 'age'] }));
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.termsAgreedAt).toBeDefined();
expect(user.notificationPrefs.marketing).toBe(false);
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort();
expect(rows).toEqual(['age_v1_grant', 'location_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
});
});
describe('fnAccountWithdraw', () => {
it('SNS 연동이 남아 있으면 E-AUTH-14 (비정상 탈퇴)', async () => {
await getAuth().createUser({ uid: U });
await getAuth().updateUser(U, { providerToLink: { providerId: 'google.com', uid: 'g1' } });
await getAuth().updateUser(U, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: 'k1' } });
expect(await codeOf(fnAccountWithdraw.run(as(U, {})))).toBe('E-AUTH-14');
expect((await getAuth().getUser(U)).uid).toBe(U);
});
it('개인식별정보를 지우고 원장은 남기며 Auth 계정을 지운다', async () => {
await getAuth().createUser({ uid: U });
await db().doc(`users/${U}`).set({ nickname: '팬', email: 'a@b.c', status: 'active' });
await db().doc(`users/${U}/popTransactions/t1`).set({ amount: 10 });
expect(await fnAccountWithdraw.run(as(U, {}))).toEqual({ withdrawn: true });
const user = (await db().doc(`users/${U}`).get()).data()!;
expect(user.status).toBe('withdrawn');
expect(user.nickname).toBeNull();
expect(user.email).toBeNull();
expect((await db().doc(`users/${U}/popTransactions/t1`).get()).exists).toBe(true);
await expect(getAuth().getUser(U)).rejects.toBeDefined();
});
});
describe('카카오 이메일 · 탈퇴 후 재가입', () => {
it('동의한 이메일만 사용자 문서에 남기고 · 없어도 로그인된다', async () => {
stubKakao();
await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters<typeof fnAuthKakao.run>[0]);
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBeUndefined();
stubKakao('fan@kakao.com');
await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters<typeof fnAuthKakao.run>[0]);
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com');
});
it('탈퇴한 kakao:<id> 문서가 있으면 새 UID 로 만든다', async () => {
stubKakao();
await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).set({ status: 'withdrawn' });
const { token } = await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters<typeof fnAuthKakao.run>[0]) as { token: string };
const uid = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string;
expect(uid).not.toBe(kakaoUidOf(KAKAO_ID));
expect(uid.startsWith(`${kakaoUidOf(KAKAO_ID)}:`)).toBe(true);
await getAuth().deleteUser(uid);
});
});
+2 -4
View File
@@ -93,13 +93,11 @@ describe('귀속 제한', () => {
expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1); expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1);
}); });
it('추천인 하루 상한(referralDailyCapPerReferrer)을 넘으면 귀속만 남고 capped', async () => { it('추천인 하루 상한이 없다 — 같은 날 여러 명을 추천해도 모두 pending (10/3 통합회신 7항)', async () => {
await db().doc('config/policy').set({ referralDailyCapPerReferrer: 1 }); await db().doc('config/policy').set({ referralDailyCapPerReferrer: 1 });
const codeA = await inviteCodeOf(A); const codeA = await inviteCodeOf(A);
expect((await attribute(B, codeA)).reward).toBe('pending'); expect((await attribute(B, codeA)).reward).toBe('pending');
expect((await attribute(C, codeA)).reward).toBe('capped'); expect((await attribute(C, codeA)).reward).toBe('pending');
await complete(C);
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
}); });
it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => { it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => {
+3 -3
View File
@@ -169,9 +169,9 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
} }
Future<void> _startPush(PushMessaging push) async { Future<void> _startPush(PushMessaging push) async {
// 권한을 먼저 묻는다. 거부해도 나머지는 계속한다 — 토큰과 구독은 사용자가 // 알림 권한은 여기서 묻지 않는다 — 온보딩 ⑥ 권한 안내가 쓰임을 설명한 뒤 묻는다
// 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다. // (FR-CMN-08 · 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
await push.requestPermission(); // 사용자가 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
await push.syncToken(); await push.syncToken();
// 팬덤 ID 는 PushMessaging 이 사용자 문서에서 직접 읽는다. // 팬덤 ID 는 PushMessaging 이 사용자 문서에서 직접 읽는다.
+6
View File
@@ -1,3 +1,4 @@
import '../../features/auth/onboarding_data.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:async'; import 'dart:async';
@@ -66,6 +67,11 @@ class Repositories {
? FirestoreInsideRepository(backend) ? FirestoreInsideRepository(backend)
: const SampleInsideRepository(); : const SampleInsideRepository();
/// 온보딩 6단계(약관 · 프로필 · 관심 아티스트 · 행성 가입).
late final OnboardingRepository onboarding = _live
? FirestoreOnboardingRepository(backend)
: SampleOnboardingRepository();
late final MyRepository my = _live late final MyRepository my = _live
? FirestoreMyRepository(backend) ? FirestoreMyRepository(backend)
: SampleMyRepository(); : SampleMyRepository();
+48 -6
View File
@@ -7,8 +7,10 @@ import '../theme/tokens.dart';
import '../ui/app_icons.dart'; import '../ui/app_icons.dart';
import '../../features/auth/auth_repository.dart'; import '../../features/auth/auth_repository.dart';
import '../../features/auth/login_screen.dart'; import '../../features/auth/login_screen.dart';
import '../../features/auth/onboarding_screens.dart';
import '../../features/auth/signup_screens.dart'; import '../../features/auth/signup_screens.dart';
import '../../features/galaxy/galaxy_home_screen.dart'; import '../../features/galaxy/galaxy_home_screen.dart';
import '../../features/home/coach_mark.dart';
import '../../features/home/home_screen.dart'; import '../../features/home/home_screen.dart';
import '../../features/inside/inside_screen.dart'; import '../../features/inside/inside_screen.dart';
import '../../features/my/my_screen.dart'; import '../../features/my/my_screen.dart';
@@ -79,11 +81,15 @@ List<AppTab> visibleTabs(FeatureFlags flags) => [
const String loginPath = '/login'; const String loginPath = '/login';
const String signupCompletePath = '/welcome'; const String signupCompletePath = '/welcome';
const String introPath = '/intro';
const String onboardingPath = '/onboarding';
/// 5탭 셸 라우트 + 온보딩(로그인) 라우트. /// 5탭 셸 라우트 + 온보딩 라우트.
/// ///
/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · /// 온보딩 6단계(10/3 통합회신 1항): ① 소개 3장([introPath], 첫 실행 · 로그인 전) → 로그인 →
/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다. /// ②~⑥ 약관 · 프로필 · 관심 아티스트 · 행성 가입/성운 생성 · 권한 안내([onboardingPath]) →
/// 가입 완료([signupCompletePath]) → HOME(Coach Mark). 서버에 약관 동의가 없는 계정은
/// 「로그인」 버튼으로 들어왔어도 셸로 가지 못하고 [onboardingPath] 로 간다.
GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
// 저장소 선택은 앱이 뜰 때 한 번만 한다. 탭을 옮길 때마다 다시 만들면 // 저장소 선택은 앱이 뜰 때 한 번만 한다. 탭을 옮길 때마다 다시 만들면
// Firestore 커서 같은 화면 상태가 매번 초기화된다. // Firestore 커서 같은 화면 상태가 매번 초기화된다.
@@ -95,11 +101,23 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
refreshListenable: auth, refreshListenable: auth,
redirect: (context, state) { redirect: (context, state) {
final at = state.matchedLocation; final at = state.matchedLocation;
if (!auth.isSignedIn) return at == loginPath ? null : loginPath; if (!auth.isSignedIn) {
final entry = auth.introPending ? introPath : loginPath;
return at == entry ? null : entry;
}
if (auth.onboarding != OnboardingGate.done) {
return at == onboardingPath ? null : onboardingPath;
}
if (auth.justSignedUp) if (auth.justSignedUp)
return at == signupCompletePath ? null : signupCompletePath; return at == signupCompletePath ? null : signupCompletePath;
if (at == loginPath || at == signupCompletePath) if (const [
loginPath,
signupCompletePath,
introPath,
onboardingPath,
].contains(at)) {
return appTabs.first.path; return appTabs.first.path;
}
return null; return null;
}, },
routes: [ routes: [
@@ -107,6 +125,24 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
path: loginPath, path: loginPath,
builder: (_, _) => LoginScreen(auth: auth), builder: (_, _) => LoginScreen(auth: auth),
), ),
GoRoute(
path: introPath,
builder: (_, _) => IntroScreen(onDone: auth.finishIntro),
),
GoRoute(
path: onboardingPath,
builder: (_, _) => ListenableBuilder(
listenable: auth,
builder: (_, _) => auth.onboarding == OnboardingGate.checking
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: OnboardingFlow(
auth: auth,
repository: repos.onboarding,
home: repos.home,
push: repos.push,
),
),
),
GoRoute( GoRoute(
path: signupCompletePath, path: signupCompletePath,
builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup), builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup),
@@ -230,7 +266,13 @@ class AppShell extends StatelessWidget {
), ),
destinations: [ destinations: [
for (final tab in tabs) for (final tab in tabs)
NavigationDestination(icon: tab.icon, label: tab.label), NavigationDestination(
// Coach Mark ② 가 MY 위치를 가리킨다(coach_mark.dart).
icon: tab.path == appTabs.last.path
? KeyedSubtree(key: coachMyTabKey, child: tab.icon)
: tab.icon,
label: tab.label,
),
], ],
), ),
), ),
+72 -2
View File
@@ -8,21 +8,84 @@ import 'firebase_account_linker.dart';
import 'social_providers.dart'; import 'social_providers.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. /// 온보딩 관문 — 로그인한 계정이 가입 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`).
enum OnboardingGate { checking, required, done }
/// 로그인 상태 보관소. 라우터가 이걸 듣고 소개/로그인/온보딩/셸을 가른다.
class AuthRepository extends ChangeNotifier { class AuthRepository extends ChangeNotifier {
AuthRepository({ AuthRepository({
List<SocialAuthProvider>? providers, List<SocialAuthProvider>? providers,
AuthSession? session, AuthSession? session,
EmailAuthProvider? qaEmailProvider, EmailAuthProvider? qaEmailProvider,
bool? releaseMode, bool? releaseMode,
Future<bool> Function(String uid)? needsOnboarding,
bool introPending = false,
Future<void> Function()? onIntroDone,
}) : providers = providers ?? _defaultProviders(), }) : providers = providers ?? _defaultProviders(),
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(), _qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
_session = session, _session = session,
_account = session?.currentAccount, _needsOnboarding = needsOnboarding,
_introPending = introPending,
_onIntroDone = onIntroDone,
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다 // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다). // (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
_releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) { _releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) {
_sessionSubscription = session?.accountChanges.listen(_replaceAccount); _sessionSubscription = session?.accountChanges.listen(_replaceAccount);
_replaceAccount(session?.currentAccount);
}
/// 이 계정이 온보딩(약관부터)을 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트).
///
/// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼 구분이나
/// 제공자(카카오 커스텀 토큰 포함)와 무관하게, 서버에 약관 동의 기록이 없으면 셸에 들어가지 못한다.
final Future<bool> Function(String uid)? _needsOnboarding;
OnboardingGate _gate = OnboardingGate.done;
OnboardingGate get onboarding => _gate;
/// 가입 모드에서 제공자 인증 **전에** 동의한 약관(Figma `177:9451` → `179:24230`).
/// 인증 뒤 온보딩이 이 값으로 동의를 기록하고 약관 단계를 건너뛴다.
Set<String>? pendingConsent;
/// 온보딩 6단계를 마쳤다 — 가입 완료 화면(`182:29344`)을 한 번 띄우고 셸로 간다.
void completeOnboarding() {
_gate = OnboardingGate.done;
pendingConsent = null;
_justSignedUp = true;
notifyListeners();
}
Future<void> _checkOnboarding(AuthAccount account) async {
final check = _needsOnboarding;
if (check == null || account.isMock) {
_gate = OnboardingGate.done;
return;
}
_gate = OnboardingGate.checking;
bool required;
try {
required = await check(account.uid);
} catch (e) {
// 판정하지 못하면 약관부터 다시 받는다 — 동의 없는 계정이 셸로 새는 쪽이 더 나쁘다.
debugPrint('[AuthRepository] onboarding check failed: ${e.runtimeType}');
required = true;
}
if (_account?.uid != account.uid) return;
_gate = required ? OnboardingGate.required : OnboardingGate.done;
notifyListeners();
}
/// 앱 소개 3장(온보딩 ①)을 아직 보지 않았다 — 첫 실행에서 로그인 화면 앞에 한 번.
bool get introPending => _introPending;
bool _introPending;
final Future<void> Function()? _onIntroDone;
/// 소개를 넘겼다(「건너뛰기」 포함) — FR-CMN-03 「건너뛰기를 눌러도 로그인 화면으로」.
void finishIntro() {
if (!_introPending) return;
_introPending = false;
unawaited(_onIntroDone?.call());
notifyListeners();
} }
final bool _releaseMode; final bool _releaseMode;
@@ -192,7 +255,14 @@ class AuthRepository extends ChangeNotifier {
void _replaceAccount(AuthAccount? next) { void _replaceAccount(AuthAccount? next) {
if (next == null) _justSignedUp = false; if (next == null) _justSignedUp = false;
if (_sameAccount(_account, next)) return; if (_sameAccount(_account, next)) return;
final uidChanged = _account?.uid != next?.uid;
_account = next; _account = next;
if (next == null) {
_gate = OnboardingGate.done;
pendingConsent = null;
} else if (uidChanged) {
unawaited(_checkOnboarding(next));
}
notifyListeners(); notifyListeners();
} }
+6 -4
View File
@@ -213,10 +213,12 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
Future<void> _signIn(SocialProvider provider) async { Future<void> _signIn(SocialProvider provider) async {
if (widget.signUp) { if (widget.signUp) {
final agreed = await Navigator.of( final agreed = await Navigator.of(context).push<Set<String>>(
context, MaterialPageRoute(builder: (_) => const TermsScreen()),
).push<bool>(MaterialPageRoute(builder: (_) => const TermsScreen())); );
if (agreed != true || !mounted) return; if (agreed == null || !mounted) return;
// 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다).
widget.auth.pendingConsent = agreed;
} }
setState(() => _busy = true); setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider, signUp: widget.signUp); final outcome = await widget.auth.signIn(provider, signUp: widget.signUp);
+171
View File
@@ -0,0 +1,171 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../core/data/backend.dart';
/// 온보딩 6단계(10/3 통합회신 1항 · 해석 확인 Q-OB-02 A · FR-CMN-03~08)가 서버와 주고받는 것.
abstract interface class OnboardingRepository {
/// ② 약관 — `fnSignupConsent`. 필수 누락은 서버가 거부한다.
Future<void> agree(Set<String> agreed, String version);
/// ③ 프로필 — 닉네임(2~20 · 중복 허용) · 국가 · 표시 언어. 지금 값(있으면)을 돌려준다.
Future<({String nickname, String? country})> currentProfile();
Future<void> saveProfile({
required String nickname,
required String country,
required String locale,
});
/// ③ 추천인 코드(선택) — `fnReferralAttribute`(10/3 7항 「가입 직후 온보딩에 선택 입력」).
Future<void> attributeReferral(String code);
/// ④ 관심 아티스트 — 팬덤 행성/성운 목록. [query] 가 있으면 이름 앞부분으로 찾는다.
Future<List<OnboardingFandom>> fandoms({String query = ''});
/// ⑤ 행성 가입 — 고른 순서대로 `fnFandomJoin`. 대표가 없던 계정은 첫 가입이 대표가 된다(C-15).
Future<void> join(List<String> fandomIds);
}
class OnboardingFandom {
const OnboardingFandom({
required this.id,
required this.name,
required this.members,
required this.nebula,
});
final String id;
final String name;
final int members;
/// Stage0 성운인가.
final bool nebula;
}
/// 계정이 온보딩을 거쳐야 하는가 — 서버에 가입 약관 동의(`termsAgreedAt`)가 없으면 그렇다.
/// 트리거가 아직 사용자 문서를 만들지 않았어도(갓 생성된 계정) 그렇다.
Future<bool> needsOnboardingFirestore(String uid) async {
final snap = await FirebaseFirestore.instance.doc('users/$uid').get();
return snap.data()?['termsAgreedAt'] == null;
}
class FirestoreOnboardingRepository implements OnboardingRepository {
FirestoreOnboardingRepository(this._backend);
final Backend _backend;
@override
Future<void> agree(Set<String> agreed, String version) => _backend.call(
'fnSignupConsent',
{'agreed': agreed.toList(), 'version': version},
);
@override
Future<({String nickname, String? country})> currentProfile() async {
final data =
(await _backend.db.doc('users/${_backend.requireUid}').get()).data() ??
const <String, dynamic>{};
final nickname = data['nickname'];
final country = data['countryCode'];
return (
nickname: nickname is String ? nickname : '',
country: country is String && country.isNotEmpty ? country : null,
);
}
@override
Future<void> saveProfile({
required String nickname,
required String country,
required String locale,
}) => _backend.db.doc('users/${_backend.requireUid}').set({
'nickname': nickname,
'countryCode': country,
'locale': locale,
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
@override
Future<void> attributeReferral(String code) => _backend.call(
'fnReferralAttribute',
{'referralCode': code, 'channel': 'onboarding'},
);
@override
Future<List<OnboardingFandom>> fandoms({String query = ''}) async {
final col = _backend.db.collection('fandoms');
final q = query.trim();
// 검색은 이름 앞부분 일치(단일 필드 정렬이라 별도 색인이 필요 없다). 목록은 FPI 순.
// ponytail: 페이지 50 건 — 팬덤이 늘면 스크롤 이어읽기를 붙인다.
final snap =
await (q.isEmpty
? col.orderBy('fpiScore', descending: true)
: col
.orderBy('name')
.where('name', isGreaterThanOrEqualTo: q)
.where('name', isLessThan: '$q'))
.limit(50)
.get();
return [
for (final d in snap.docs)
OnboardingFandom(
id: d.id,
name: (d.data()['name'] as String?) ?? d.id,
members: (d.data()['memberCount'] as num?)?.toInt() ?? 0,
nebula: d.data()['stage'] == 0,
),
];
}
@override
Future<void> join(List<String> fandomIds) async {
for (final id in fandomIds) {
await _backend.call('fnFandomJoin', {'fandomId': id});
}
}
}
/// 목 모드 · 테스트용. **정책값이 아니다.**
class SampleOnboardingRepository implements OnboardingRepository {
SampleOnboardingRepository();
final joined = <String>[];
Set<String>? agreed;
String? referral;
({String nickname, String country, String locale})? profile;
@override
Future<void> agree(Set<String> agreed, String version) async =>
this.agreed = agreed;
@override
Future<({String nickname, String? country})> currentProfile() async =>
(nickname: '', country: null);
@override
Future<void> saveProfile({
required String nickname,
required String country,
required String locale,
}) async => profile = (nickname: nickname, country: country, locale: locale);
@override
Future<void> attributeReferral(String code) async => referral = code;
@override
Future<List<OnboardingFandom>> fandoms({String query = ''}) async => [
for (final f in const [
OnboardingFandom(id: 'bts', name: 'BTS', members: 12840, nebula: false),
OnboardingFandom(
id: 'newjeans',
name: 'NewJeans',
members: 11230,
nebula: false,
),
OnboardingFandom(id: 'aespa', name: 'aespa', members: 8, nebula: true),
])
if (f.name.toLowerCase().startsWith(query.trim().toLowerCase())) f,
];
@override
Future<void> join(List<String> fandomIds) async => joined.addAll(fandomIds);
}
@@ -0,0 +1,720 @@
import 'package:flutter/material.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart';
import '../../core/theme/tokens.dart';
import '../home/coach_mark.dart';
import '../home/home_data.dart';
import '../home/nebula_create_screen.dart';
import '../my/my_data.dart'
show nicknameMaxLength, nicknameMinLength, nicknameReserved;
import 'auth_repository.dart';
import 'onboarding_data.dart';
import 'signup_screens.dart';
/// 온보딩 ① 소개 3장 — FR-CMN-03 「소개는 3장으로 고정 · 건너뛰기를 눌러도 로그인 화면으로」.
///
/// 앱 Figma 사본에 소개 화면이 없다(해석 확인 Q-OB-02 A 「사본의 컴포넌트 · 색 · 글꼴 · 화면 틀로
/// 구성」). 문구 · 이미지는 발주사 제공본이 오면 바꾼다(해석 확인 요청 E-Q7).
class IntroScreen extends StatefulWidget {
const IntroScreen({super.key, required this.onDone});
final VoidCallback onDone;
@override
State<IntroScreen> createState() => _IntroScreenState();
}
class _IntroScreenState extends State<IntroScreen> {
final _pages = PageController();
int _page = 0;
@override
void dispose() {
_pages.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final slides = [
(l10n.onbIntro1Title, l10n.onbIntro1Body),
(l10n.onbIntro2Title, l10n.onbIntro2Body),
(l10n.onbIntro3Title, l10n.onbIntro3Body),
];
final last = _page == slides.length - 1;
return Scaffold(
body: SafeArea(
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: widget.onDone,
child: Text(l10n.onbSkip),
),
),
Expanded(
child: PageView(
controller: _pages,
onPageChanged: (i) => setState(() => _page = i),
children: [
for (final (title, body) in slides)
Padding(
padding: const EdgeInsets.all(AppSpace.s6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox.square(
dimension: 120,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.cosmicAccent,
shape: BoxShape.circle,
),
),
),
const SizedBox(height: AppSpace.s6),
Text(
title,
textAlign: TextAlign.center,
style: _title,
),
const SizedBox(height: AppSpace.s3),
Text(body, textAlign: TextAlign.center, style: _body),
],
),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < slides.length; i++)
Container(
margin: const EdgeInsets.all(4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _page
? AppColors.cosmicAccent
: AppColors.onCosmicMuted,
),
),
],
),
Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: _PrimaryButton(
label: last ? l10n.commonStart : l10n.commonNext,
onPressed: last
? widget.onDone
: () => _pages.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
),
),
),
],
),
),
);
}
}
enum _Step { terms, profile, artist, planet, permissions }
/// 온보딩 ②~⑥ — 로그인(가입 · 로그인 버튼 구분 없이) 뒤 서버에 약관 동의가 없으면 여기로 온다.
///
/// 순서는 10/3 통합회신 1항 그대로: 약관 → 프로필 → 관심 아티스트/Primary → 행성 가입 또는 성운
/// 생성 → 권한 안내. 약관을 기록한 뒤 앱을 닫으면 다음 실행은 HOME(`홈_초기사용자`)으로 간다
/// (Q-OB-02 A 「`홈_초기사용자` — 가입 흐름을 마치기 전에 앱을 닫은 사용자에게 사본대로」).
class OnboardingFlow extends StatefulWidget {
const OnboardingFlow({
super.key,
required this.auth,
required this.repository,
required this.home,
this.push,
});
final AuthRepository auth;
final OnboardingRepository repository;
/// 성운 생성(`홈/성운 생성` 사본 화면)이 쓰는 저장소.
final HomeRepository home;
/// 권한 안내의 OS 알림 팝업. 목 모드에서는 null.
final PushMessaging? push;
@override
State<OnboardingFlow> createState() => _OnboardingFlowState();
}
class _OnboardingFlowState extends State<OnboardingFlow> {
_Step _step = _Step.terms;
bool _busy = false;
/// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary).
final _picked = <OnboardingFandom>[];
@override
void initState() {
super.initState();
// 가입 모드는 제공자 인증 전에 약관을 받았다 — 다시 묻지 않고 기록만 한다.
final pending = widget.auth.pendingConsent;
if (pending != null) _agree(pending);
}
Future<void> _run(Future<void> Function() task) async {
setState(() => _busy = true);
try {
await task();
} on BackendException catch (e) {
_toast(e.message);
} catch (_) {
if (mounted) _toast(context.l10n.commonRequestFailed);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _toast(String message) => ScaffoldMessenger.maybeOf(context)
?..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
void _agree(Set<String> agreed) {
if (_busy) return;
_run(() async {
await widget.repository.agree(agreed, signupTermsVersion);
if (mounted) setState(() => _step = _Step.profile);
});
}
void _back() => setState(
() => _step = switch (_step) {
_Step.artist => _Step.profile,
_Step.planet => _Step.artist,
final s => s,
},
);
Future<void> _finish() async {
try {
await widget.push?.requestPermission();
} catch (_) {
// 거부 · 플러그인 오류여도 다음으로 간다(FR-CMN-08).
}
await armCoachMark();
widget.auth.completeOnboarding();
}
@override
Widget build(BuildContext context) {
final body = switch (_step) {
_Step.terms => TermsScreen(onAgreed: _agree, onBack: widget.auth.signOut),
_Step.profile => _ProfileStep(
repository: widget.repository,
onDone: () => setState(() => _step = _Step.artist),
),
_Step.artist => _ArtistStep(
repository: widget.repository,
picked: _picked,
onNext: () => setState(() => _step = _Step.planet),
onBack: _back,
),
_Step.planet => _PlanetStep(
picked: _picked,
busy: _busy,
onBack: _back,
onJoin: () => _run(() async {
await widget.repository.join([for (final f in _picked) f.id]);
if (mounted) setState(() => _step = _Step.permissions);
}),
onCreateNebula: () async {
final created = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => NebulaCreateScreen(repository: widget.home),
),
);
if (created != null && mounted) {
setState(() => _step = _Step.permissions);
}
},
),
_Step.permissions => _PermissionStep(onContinue: _finish),
};
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _back();
},
child: body,
);
}
}
/// ③ 프로필 — 닉네임 2~20(중복 허용 · B-52) · 국가/지역 · 표시 언어 · 추천인 코드(선택).
/// 사본 `MY/프로필 설정` 구성을 따른다. 국가 기본값은 기기 지역(Figma `554:24126` 「앱 기동 단계에서
/// 조회된 국가/언어를 디폴트」).
class _ProfileStep extends StatefulWidget {
const _ProfileStep({required this.repository, required this.onDone});
final OnboardingRepository repository;
final VoidCallback onDone;
@override
State<_ProfileStep> createState() => _ProfileStepState();
}
class _ProfileStepState extends State<_ProfileStep> {
final _nickname = TextEditingController();
final _referral = TextEditingController();
String? _country = _deviceCountry();
Locale? _locale;
String? _nicknameError;
String? _referralError;
bool _busy = false;
/// 기기 지역. 없으면 비워 두고 사용자가 고르게 한다(임의 국가로 채우지 않는다).
static String? _deviceCountry() {
final code = WidgetsBinding.instance.platformDispatcher.locale.countryCode
?.toUpperCase();
return code != null && isoCountries.contains(code) ? code : null;
}
@override
void initState() {
super.initState();
_nickname.addListener(() => setState(() => _nicknameError = null));
widget.repository.currentProfile().then((p) {
if (!mounted) return;
setState(() {
if (_nickname.text.isEmpty) _nickname.text = p.nickname;
if (p.country != null && isoCountries.contains(p.country)) {
_country = p.country!;
}
});
}, onError: (_) {});
}
@override
void dispose() {
_nickname.dispose();
_referral.dispose();
super.dispose();
}
bool get _valid {
final n = _nickname.text.trim().characters.length;
return n >= nicknameMinLength && n <= nicknameMaxLength && _country != null;
}
Future<void> _save() async {
final l10n = context.l10n;
final nickname = _nickname.text.trim();
if (nicknameReserved(nickname)) {
setState(() => _nicknameError = l10n.nicknameReserved);
return;
}
final scope = LocaleScope.maybeOf(context);
final locale = _locale ?? scope?.locale ?? fallbackLocale;
setState(() => _busy = true);
try {
await widget.repository.saveProfile(
nickname: nickname,
country: _country!,
locale: localeTag(locale),
);
if (_locale != null) await scope?.select(_locale!);
final code = _referral.text.trim().toUpperCase();
if (code.isNotEmpty) {
try {
await widget.repository.attributeReferral(code);
} on BackendException catch (e) {
if (mounted) setState(() => _referralError = e.message);
return;
}
}
widget.onDone();
} on BackendException catch (e) {
if (mounted) setState(() => _nicknameError = e.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
return _StepScaffold(
title: l10n.onbProfileTitle,
action: _PrimaryButton(
label: l10n.commonNext,
onPressed: _valid && !_busy ? _save : null,
),
children: [
TextField(
controller: _nickname,
maxLength: nicknameMaxLength,
decoration: InputDecoration(
labelText: l10n.settingsNickname,
helperText: l10n.onbNicknameHint,
errorText: _nicknameError,
),
),
const SizedBox(height: AppSpace.s3),
DropdownButtonFormField<String>(
initialValue: _country,
isExpanded: true,
decoration: InputDecoration(labelText: l10n.onbCountry),
items: [
for (final c in isoCountries)
DropdownMenuItem(value: c, child: Text('${countryFlag(c)} $c')),
],
onChanged: (v) => setState(() => _country = v ?? _country),
),
const SizedBox(height: AppSpace.s3),
DropdownButtonFormField<Locale>(
initialValue: appLocales.contains(current) ? current : null,
isExpanded: true,
decoration: InputDecoration(labelText: l10n.onbLanguage),
items: [
for (final l in appLocales)
DropdownMenuItem(value: l, child: Text(languageName(l))),
],
onChanged: (v) => setState(() => _locale = v),
),
const SizedBox(height: AppSpace.s3),
TextField(
controller: _referral,
textCapitalization: TextCapitalization.characters,
maxLength: 10,
onChanged: (_) => setState(() => _referralError = null),
decoration: InputDecoration(
labelText: l10n.onbReferral,
helperText: l10n.onbReferralHint,
errorText: _referralError,
),
),
],
);
}
}
/// ④ 관심 아티스트 / Primary Fandom — 최소 1개(FR-CMN-06). 처음 고른 팬덤이 대표가 된다.
/// 찾는 아티스트가 없으면 ⑤ 에서 성운을 만든다.
class _ArtistStep extends StatefulWidget {
const _ArtistStep({
required this.repository,
required this.picked,
required this.onNext,
required this.onBack,
});
final OnboardingRepository repository;
final List<OnboardingFandom> picked;
final VoidCallback onNext;
final VoidCallback onBack;
@override
State<_ArtistStep> createState() => _ArtistStepState();
}
class _ArtistStepState extends State<_ArtistStep> {
late Future<List<OnboardingFandom>> _list = widget.repository.fandoms();
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
void _toggle(OnboardingFandom f) => setState(() {
final i = widget.picked.indexWhere((p) => p.id == f.id);
i >= 0 ? widget.picked.removeAt(i) : widget.picked.add(f);
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final picked = widget.picked;
return _StepScaffold(
title: l10n.onbArtistTitle,
subtitle: l10n.onbArtistBody,
onBack: widget.onBack,
action: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PrimaryButton(
label: l10n.commonNext,
onPressed: picked.isEmpty ? null : widget.onNext,
),
TextButton(
onPressed: () {
picked.clear();
widget.onNext();
},
child: Text(l10n.onbArtistNone),
),
],
),
children: [
TextField(
controller: _query,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: l10n.onbArtistSearch,
prefixIcon: const Icon(Icons.search),
),
onSubmitted: (q) =>
setState(() => _list = widget.repository.fandoms(query: q)),
),
const SizedBox(height: AppSpace.s3),
FutureBuilder<List<OnboardingFandom>>(
future: _list,
builder: (context, snap) {
if (snap.hasError) {
return TextButton(
onPressed: () => setState(
() => _list = widget.repository.fandoms(query: _query.text),
),
child: Text(l10n.commonRetry),
);
}
final list = snap.data;
if (list == null) {
return const Center(child: CircularProgressIndicator());
}
if (list.isEmpty) return Text(l10n.commonNoResults);
return Column(
children: [
for (final f in list)
CheckboxListTile(
value: picked.any((p) => p.id == f.id),
onChanged: (_) => _toggle(f),
title: Text(f.name),
subtitle: Text(
[
if (f.nebula) l10n.onbNebulaTag,
l10n.onbMembers('${f.members}'),
].join(' · '),
),
secondary: picked.isNotEmpty && picked.first.id == f.id
? Chip(label: Text(l10n.onbPrimary))
: null,
),
],
);
},
),
],
);
}
}
/// ⑤ 행성 가입 또는 성운 생성 — 성운 생성은 사본 `홈/성운 생성`(`340:75466`) 그대로.
class _PlanetStep extends StatelessWidget {
const _PlanetStep({
required this.picked,
required this.busy,
required this.onBack,
required this.onJoin,
required this.onCreateNebula,
});
final List<OnboardingFandom> picked;
final bool busy;
final VoidCallback onBack;
final VoidCallback onJoin;
final VoidCallback onCreateNebula;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return _StepScaffold(
title: l10n.onbPlanetTitle,
onBack: onBack,
action: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (picked.isNotEmpty)
_PrimaryButton(
label: l10n.onbPlanetJoin,
onPressed: busy ? null : onJoin,
),
const SizedBox(height: AppSpace.s2),
OutlinedButton(
onPressed: busy ? null : onCreateNebula,
child: Text(l10n.onbPlanetCreate),
),
],
),
children: [
for (final (i, f) in picked.indexed)
ListTile(
title: Text(f.name),
trailing: i == 0 ? Chip(label: Text(l10n.onbPrimary)) : null,
),
],
);
}
}
/// ⑥ 권한 안내 — 한 화면에서 쓰임을 설명하고 OS 팝업은 알림만(FR-CMN-08 · C-84). 건강은 걷기 Quest,
/// 위치는 위치 Quest 에 처음 들어갈 때 요청한다. 거부해도 다음으로 간다.
class _PermissionStep extends StatefulWidget {
const _PermissionStep({required this.onContinue});
final Future<void> Function() onContinue;
@override
State<_PermissionStep> createState() => _PermissionStepState();
}
class _PermissionStepState extends State<_PermissionStep> {
bool _busy = false;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return _StepScaffold(
title: l10n.onbPermTitle,
subtitle: l10n.onbPermBody,
action: _PrimaryButton(
label: l10n.onbPermContinue,
onPressed: _busy
? null
: () async {
setState(() => _busy = true);
await widget.onContinue();
},
),
children: [
for (final (icon, title, desc) in [
(
Icons.notifications_outlined,
l10n.onbPermPush,
l10n.onbPermPushDesc,
),
(Icons.directions_walk, l10n.onbPermHealth, l10n.onbPermHealthDesc),
(
Icons.place_outlined,
l10n.onbPermLocation,
l10n.onbPermLocationDesc,
),
])
ListTile(
leading: Icon(icon, color: AppColors.cosmicAccent),
title: Text(title),
subtitle: Text(desc),
),
],
);
}
}
/// 단계 화면 틀 — 사본 가입 화면과 같은 코스믹 다크 · 하단 고정 버튼.
class _StepScaffold extends StatelessWidget {
const _StepScaffold({
required this.title,
required this.action,
required this.children,
this.subtitle,
this.onBack,
});
final String title;
final String? subtitle;
final Widget action;
final List<Widget> children;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: onBack == null ? null : BackButton(onPressed: onBack),
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
Text(title, style: _title),
if (subtitle != null) ...[
const SizedBox(height: AppSpace.s2),
Text(subtitle!, style: _body),
],
const SizedBox(height: AppSpace.s5),
...children,
],
),
),
Padding(padding: const EdgeInsets.all(AppSpace.s4), child: action),
],
),
),
);
}
class _PrimaryButton extends StatelessWidget {
const _PrimaryButton({required this.label, required this.onPressed});
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) => FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
disabledBackgroundColor: const Color(0xFF333333),
disabledForegroundColor: const Color(0xFF777777),
),
child: Text(label),
);
}
const _title = TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
);
const _body = TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
height: 1.6,
color: Color(0xFFCDCDCD),
);
/// 국기 이모지 — 지역 표시 기호 두 글자.
String countryFlag(String code) =>
String.fromCharCodes(code.codeUnits.map((c) => c - 0x41 + 0x1F1E6));
/// ISO 3166-1 alpha-2 전체. 랭킹 집계 · Admin 사용자 일람의 「거주 국가/지역」 값이다.
final List<String> isoCountries =
('AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ '
'BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK '
'DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ '
'GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH '
'KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM '
'MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF '
'PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM '
'SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US '
'UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW')
.split(' ');
+28 -8
View File
@@ -5,19 +5,34 @@ import '../../core/theme/tokens.dart';
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의). /// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
/// ///
/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를 /// 필수 4항을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다. /// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 온보딩은 [onAgreed] 로 받는다.
/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다.
/// ///
/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다. /// 필수 항목은 Figma 의 서비스 · 위치기반 · 만 14세에 개인정보 처리방침을 더했다(10/3 통합회신
/// 3절 4 「이용약관 · 개인정보처리방침 문구 제공」). 구성은 해석 확인 요청 E-Q1 회신 대기다.
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와
/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
class TermsScreen extends StatefulWidget { class TermsScreen extends StatefulWidget {
const TermsScreen({super.key}); const TermsScreen({super.key, this.onAgreed, this.onBack});
/// 온보딩 안에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다.
final ValueChanged<Set<String>>? onAgreed;
final VoidCallback? onBack;
@override @override
State<TermsScreen> createState() => _TermsScreenState(); State<TermsScreen> createState() => _TermsScreenState();
} }
/// 동의 기록에 남는 약관 버전. 🟡 임시 — 본문이 자리 표시 문구인 동안은 `draft` 다.
const signupTermsVersion = 'draft-1';
/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키.
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
enum _Term { enum _Term {
service(required: true, body: true), service(required: true, body: true),
privacy(required: true, body: true),
location(required: true, body: true), location(required: true, body: true),
age(required: true, body: false), age(required: true, body: false),
marketing(required: false, body: true); marketing(required: false, body: true);
@@ -29,6 +44,7 @@ enum _Term {
String label(AppLocalizations l10n) => switch (this) { String label(AppLocalizations l10n) => switch (this) {
service => l10n.termsService, service => l10n.termsService,
privacy => l10n.termsPrivacy,
location => l10n.termsLocation, location => l10n.termsLocation,
age => l10n.termsAge, age => l10n.termsAge,
marketing => l10n.termsMarketing, marketing => l10n.termsMarketing,
@@ -48,6 +64,12 @@ class _TermsScreenState extends State<TermsScreen> {
bool get _ready => bool get _ready =>
_Term.values.where((t) => t.required).every(_agreed.contains); _Term.values.where((t) => t.required).every(_agreed.contains);
void _done() {
final agreed = {for (final t in _agreed) t.name};
final cb = widget.onAgreed;
cb != null ? cb(agreed) : Navigator.of(context).pop(agreed);
}
void _toggle(_Term t) => void _toggle(_Term t) =>
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t)); setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t));
@@ -120,7 +142,7 @@ class _TermsScreenState extends State<TermsScreen> {
children: [ children: [
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: widget.onBack ?? () => Navigator.of(context).pop(),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.black, backgroundColor: Colors.black,
foregroundColor: AppColors.onCosmic, foregroundColor: AppColors.onCosmic,
@@ -131,9 +153,7 @@ class _TermsScreenState extends State<TermsScreen> {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: _ready onPressed: _ready ? _done : null,
? () => Navigator.of(context).pop(true)
: null,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent, backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent, foregroundColor: AppColors.onCosmicAccent,
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
/// 1회성 로컬 Coach Mark — 9/21 회신서 ④-2 A · 10/3 통합회신 1항 · 해석 확인 Q-OB-02 A.
///
/// 온보딩 6단계를 마치면 [armCoachMark] 가 이 기기에 「대기」를 남기고, 그 뒤 처음 ZAP 이 보이는
/// HOME 에서 [maybeShowCoachMark] 가 2단계(① Zap 버튼 ② 하단 탭 MY)를 띄운 뒤 지운다.
/// 서버 데이터는 없다(단말당 1회 · 재설치하면 다시 나올 수 있다).
const _pendingKey = 'coach.pending';
/// 하단 탭 MY 아이콘 위치를 찾는 열쇠 — `AppShell` 이 단다.
final coachMyTabKey = GlobalKey(debugLabel: 'coach-my-tab');
Future<void> armCoachMark() async {
try {
await (await SharedPreferences.getInstance()).setBool(_pendingKey, true);
} catch (_) {}
}
Future<void> maybeShowCoachMark(BuildContext context, GlobalKey zapKey) async {
SharedPreferences prefs;
try {
prefs = await SharedPreferences.getInstance();
} catch (_) {
return;
}
if (prefs.getBool(_pendingKey) != true || !context.mounted) return;
final zap = _rectOf(zapKey);
if (zap == null) return; // ZAP 이 없는 HOME(무소속) — 가입한 뒤 다시 본다.
await prefs.setBool(_pendingKey, false);
if (!context.mounted) return;
// 하단 탭 MY — 셸이 MY 목적지 아이콘에 [coachMyTabKey] 를 단다. 못 찾으면 ①만 보인다.
final my = _rectOf(coachMyTabKey)?.inflate(12);
final l10n = context.l10n;
await showGeneralDialog<void>(
context: context,
barrierColor: Colors.transparent,
pageBuilder: (_, _, _) => _CoachOverlay(
steps: [
(zap.inflate(8), l10n.coachZap),
if (my != null) (my, l10n.coachMy),
],
),
);
}
Rect? _rectOf(GlobalKey key) {
final box = key.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return null;
return box.localToGlobal(Offset.zero) & box.size;
}
class _CoachOverlay extends StatefulWidget {
const _CoachOverlay({required this.steps});
final List<(Rect, String)> steps;
@override
State<_CoachOverlay> createState() => _CoachOverlayState();
}
class _CoachOverlayState extends State<_CoachOverlay> {
int _i = 0;
void _next() => _i + 1 < widget.steps.length
? setState(() => _i++)
: Navigator.of(context).pop();
@override
Widget build(BuildContext context) {
final (hole, text) = widget.steps[_i];
final above = hole.center.dy > MediaQuery.sizeOf(context).height / 2;
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _next,
child: CustomPaint(painter: _Dim(hole)),
),
),
Positioned(
left: 24,
right: 24,
top: above ? null : hole.bottom + 16,
bottom: above
? MediaQuery.sizeOf(context).height - hole.top + 16
: null,
child: Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
text,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.onCosmic,
),
),
const SizedBox(height: AppSpace.s3),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.onbSkip),
),
FilledButton(
onPressed: _next,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
),
child: Text(
_i + 1 < widget.steps.length
? context.l10n.commonNext
: context.l10n.commonConfirm,
),
),
],
),
],
),
),
),
),
],
);
}
}
/// 나머지 화면은 어둡게, 가리킨 곳만 둥글게 뚫는다.
class _Dim extends CustomPainter {
const _Dim(this.hole);
final Rect hole;
@override
void paint(Canvas canvas, Size size) {
final path = Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
..addRRect(RRect.fromRectAndRadius(hole, const Radius.circular(16)));
canvas.drawPath(
path,
Paint()..color = Colors.black.withValues(alpha: 0.72),
);
}
@override
bool shouldRepaint(_Dim old) => old.hole != hole;
}
+7
View File
@@ -1,6 +1,7 @@
// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다. // formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다.
export '../../core/format.dart' show formatPop; export '../../core/format.dart' show formatPop;
import 'coach_mark.dart';
import '../planet/planet_history_screen.dart'; import '../planet/planet_history_screen.dart';
import '../galaxy/widgets/galaxy_web_view.dart'; import '../galaxy/widgets/galaxy_web_view.dart';
import '../galaxy/galaxy_search_screen.dart'; import '../galaxy/galaxy_search_screen.dart';
@@ -121,6 +122,12 @@ class _HomeScreenState extends State<HomeScreen> {
_snapshot = widget.repository.load(); _snapshot = widget.repository.load();
if (widget.galaxyRepository case final repository?) { if (widget.galaxyRepository case final repository?) {
_galaxy = repository.load(); _galaxy = repository.load();
// 온보딩을 마친 뒤 첫 HOME — ZAP 이 그려진 다음 Coach Mark(1회)를 띄운다.
Future.wait<Object?>([_snapshot, _galaxy!]).then((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) maybeShowCoachMark(context, _zapKey);
});
}, onError: (_) {});
_zap = ZapController( _zap = ZapController(
repository, repository,
onFailure: (message) { onFailure: (message) {
+6
View File
@@ -814,12 +814,18 @@ abstract interface class MyRepository {
/// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*. /// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*.
Future<({String current, bool latest})> appVersion(); Future<({String current, bool latest})> appVersion();
/// 서비스 탈퇴 — `fnAccountWithdraw`(Figma `554:24885`). SNS 연동이 남아 있으면 E-AUTH-14.
Future<void> withdraw();
} }
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleMyRepository implements MyRepository { class SampleMyRepository implements MyRepository {
SampleMyRepository(); SampleMyRepository();
@override
Future<void> withdraw() async {}
static final _base = DateTime(2026, 9, 1, 12); static final _base = DateTime(2026, 9, 1, 12);
String _nickname = '아미보이'; String _nickname = '아미보이';
@@ -112,7 +112,8 @@ class FirestoreMyRepository extends SampleMyRepository {
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로 // **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
// 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」 // 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」
// (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다. // (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다.
final email = _backend.email ?? ''; // Kakao(커스텀 토큰)는 Auth 이메일이 비어 있어 서버가 사용자 문서에 남긴 동의 이메일을 먼저 본다.
final email = user.strOr('email', _backend.email ?? '');
final photoUrl = user.strOr('photoUrl'); final photoUrl = user.strOr('photoUrl');
@@ -167,6 +168,9 @@ class FirestoreMyRepository extends SampleMyRepository {
}, SetOptions(merge: true)); }, SetOptions(merge: true));
} }
@override
Future<void> withdraw() => _backend.call('fnAccountWithdraw');
@override @override
Future<void> updateNickname(String nickname) async { Future<void> updateNickname(String nickname) async {
final uid = _backend.requireUid; final uid = _backend.requireUid;
+34 -7
View File
@@ -1,4 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart'; import '../../core/push/push_messaging.dart';
@@ -230,7 +234,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: l10n.settingsWithdrawConfirm, title: l10n.settingsWithdrawConfirm,
body: l10n.settingsWithdrawBody, body: l10n.settingsWithdrawBody,
action: l10n.settingsWithdraw, action: l10n.settingsWithdraw,
withdraw: true, onConfirm: _withdraw,
), ),
), ),
], ],
@@ -339,6 +343,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
await LocaleScope.maybeOf(context)?.select(picked); await LocaleScope.maybeOf(context)?.select(picked);
} }
/// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 ·
/// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다.
/// 서버도 같은 조건을 다시 본다(E-AUTH-14).
Future<void> _withdraw() async {
final l10n = context.l10n;
if (_linked.values.where((e) => e != null).length > 1) {
await _notice(l10n.settingsWithdrawLinked);
return;
}
try {
// 푸시 정리를 먼저 — 계정이 지워지면 이 기기 토큰을 지울 수 없다.
await widget.push?.clearOnSignOut();
await widget.repository.withdraw();
} on BackendException catch (e) {
if (mounted) {
await _notice(
e.code == 'E-AUTH-14' ? l10n.settingsWithdrawLinked : e.message,
);
}
return;
}
await widget.auth?.signOut();
// 「앱 종료」 — Android 만. iOS 는 앱이 스스로 종료하지 않는다(스토어 지침) — 로그인 화면으로 간다.
if (defaultTargetPlatform == TargetPlatform.android) {
await SystemNavigator.pop();
}
}
/// 안내 팝업(확인 하나) — 시안 `554:23103`. /// 안내 팝업(확인 하나) — 시안 `554:23103`.
Future<void> _notice(String message) => showAlertDialog(context, message); Future<void> _notice(String message) => showAlertDialog(context, message);
@@ -347,7 +379,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
required String title, required String title,
String? body, String? body,
required String action, required String action,
bool withdraw = false,
Future<void> Function()? onConfirm, Future<void> Function()? onConfirm,
}) async { }) async {
final ok = await showConfirmDialog( final ok = await showConfirmDialog(
@@ -356,11 +387,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
body: body, body: body,
confirmLabel: action, confirmLabel: action,
); );
if (ok == true && onConfirm != null) { if (ok == true && onConfirm != null) await onConfirm();
await onConfirm();
} else if (ok == true && withdraw && mounted) {
_toast(context.l10n.settingsWithdrawLater);
}
return ok; return ok;
} }
} }
+38 -1
View File
@@ -830,5 +830,42 @@
"questModerationRejected": "Rejected in review: {reason}. Your deposit and fee were returned.", "questModerationRejected": "Rejected in review: {reason}. Your deposit and fee were returned.",
"questSubmittedForReview": "Submitted. It will be published after review.", "questSubmittedForReview": "Submitted. It will be published after review.",
"popReasonQuestFeeRefund": "Quest fee refund", "popReasonQuestFeeRefund": "Quest fee refund",
"questGoalSteps": "Step goal" "questGoalSteps": "Step goal",
"eclipseTapToJoin": "Can't shake? Tap to join",
"termsPrivacy": "Privacy Policy and Personal Data Collection (required)",
"onbSkip": "Skip",
"onbIntro1Title": "Grow your fandom's planet",
"onbIntro1Body": "Collect POP and send it with ZAP\nto grow your fandom planet together.",
"onbIntro2Title": "Cheer with quests",
"onbIntro2Body": "Complete walking, attendance and streaming quests\nand earn POP rewards.",
"onbIntro3Title": "Protect it with fellow fans",
"onbIntro3Body": "Stop the Eclipse together and support\nyour artist through campaigns.",
"onbProfileTitle": "Set up your profile",
"onbNicknameHint": "2–20 characters · duplicates are allowed",
"onbCountry": "Country/Region",
"onbLanguage": "Display language",
"onbReferral": "Referral code (optional)",
"onbReferralHint": "Enter a code if a friend shared one",
"onbArtistTitle": "Choose the artists you follow",
"onbArtistBody": "The first artist you pick becomes your main planet. You can join several fandoms.",
"onbArtistSearch": "Search artist name",
"onbArtistNone": "I can't find my artist",
"onbPrimary": "Main",
"onbNebulaTag": "Nebula",
"onbMembers": "{count} members",
"onbPlanetTitle": "Join a planet or create a nebula",
"onbPlanetJoin": "Join selected planets",
"onbPlanetCreate": "Create a new nebula",
"onbPermTitle": "App permissions",
"onbPermBody": "These permissions are needed only for the related features. You can use the app without them.",
"onbPermPush": "Notifications",
"onbPermPushDesc": "Get Eclipse, HERO TIME and quest updates.",
"onbPermHealth": "Health data (steps)",
"onbPermHealthDesc": "Counts steps for walking quests. Requested when you first start one.",
"onbPermLocation": "Location",
"onbPermLocationDesc": "Checks arrival for location quests. Requested when you first start one.",
"onbPermContinue": "Continue",
"coachZap": "Tap to send POP to our planet",
"coachMy": "Your records are here",
"settingsWithdrawLinked": "Please unlink your connected accounts before deleting your account."
} }
+38 -1
View File
@@ -830,5 +830,42 @@
"questModerationRejected": "Rechazada en revisión: {reason}. Te devolvimos el depósito y la comisión.", "questModerationRejected": "Rechazada en revisión: {reason}. Te devolvimos el depósito y la comisión.",
"questSubmittedForReview": "Enviada. Se publicará tras la revisión.", "questSubmittedForReview": "Enviada. Se publicará tras la revisión.",
"popReasonQuestFeeRefund": "Reembolso de comisión de misión", "popReasonQuestFeeRefund": "Reembolso de comisión de misión",
"questGoalSteps": "Meta de pasos" "questGoalSteps": "Meta de pasos",
"eclipseTapToJoin": "¿No puedes agitar? Toca para participar",
"termsPrivacy": "Política de privacidad y recopilación de datos personales (obligatorio)",
"onbSkip": "Omitir",
"onbIntro1Title": "Haz crecer el planeta de tu fandom",
"onbIntro1Body": "Reúne POP y envíalo con ZAP\npara que el planeta de tu fandom crezca.",
"onbIntro2Title": "Apoya con misiones",
"onbIntro2Body": "Completa misiones de caminar, asistencia y streaming\ny gana POP.",
"onbIntro3Title": "Protégelo junto a otros fans",
"onbIntro3Body": "Detengan juntos el Eclipse y apoyen\na su artista con campañas.",
"onbProfileTitle": "Configura tu perfil",
"onbNicknameHint": "2–20 caracteres · se permiten duplicados",
"onbCountry": "País/Región",
"onbLanguage": "Idioma de visualización",
"onbReferral": "Código de referido (opcional)",
"onbReferralHint": "Introduce el código si un amigo te lo compartió",
"onbArtistTitle": "Elige a los artistas que sigues",
"onbArtistBody": "El primer artista que elijas será tu planeta principal. Puedes unirte a varios fandoms.",
"onbArtistSearch": "Buscar nombre del artista",
"onbArtistNone": "No encuentro a mi artista",
"onbPrimary": "Principal",
"onbNebulaTag": "Nebulosa",
"onbMembers": "{count} miembros",
"onbPlanetTitle": "Únete a un planeta o crea una nebulosa",
"onbPlanetJoin": "Unirse a los planetas elegidos",
"onbPlanetCreate": "Crear una nebulosa nueva",
"onbPermTitle": "Permisos de la app",
"onbPermBody": "Estos permisos solo se necesitan para las funciones relacionadas. Puedes usar la app sin ellos.",
"onbPermPush": "Notificaciones",
"onbPermPushDesc": "Recibe avisos de Eclipse, HERO TIME y misiones.",
"onbPermHealth": "Datos de salud (pasos)",
"onbPermHealthDesc": "Cuenta pasos para misiones de caminar. Se solicita al empezar la primera.",
"onbPermLocation": "Ubicación",
"onbPermLocationDesc": "Comprueba la llegada en misiones de ubicación. Se solicita al empezar la primera.",
"onbPermContinue": "Continuar",
"coachZap": "Toca para enviar POP a nuestro planeta",
"coachMy": "Aquí puedes ver tus registros",
"settingsWithdrawLinked": "Desvincula tus cuentas conectadas antes de eliminar tu cuenta."
} }
+38 -1
View File
@@ -830,5 +830,42 @@
"questModerationRejected": "審査で差し戻されました:{reason}。預けたPOPと手数料は返還済みです。", "questModerationRejected": "審査で差し戻されました:{reason}。預けたPOPと手数料は返還済みです。",
"questSubmittedForReview": "公開を申請しました。審査後に公開されます。", "questSubmittedForReview": "公開を申請しました。審査後に公開されます。",
"popReasonQuestFeeRefund": "クエスト手数料の返還", "popReasonQuestFeeRefund": "クエスト手数料の返還",
"questGoalSteps": "目標歩数" "questGoalSteps": "目標歩数",
"eclipseTapToJoin": "振るのが難しければタップで参加",
"termsPrivacy": "個人情報の収集・利用およびプライバシーポリシーへの同意(必須)",
"onbSkip": "スキップ",
"onbIntro1Title": "私たちのファンダムの惑星を育てよう",
"onbIntro1Body": "POPを集めてZAPで送ると\nファンダムの惑星が一緒に成長します。",
"onbIntro2Title": "クエストで応援しよう",
"onbIntro2Body": "ウォーキング・出席・ストリーミングのクエストを達成して\nPOPを受け取りましょう。",
"onbIntro3Title": "ファンと一緒に守ろう",
"onbIntro3Body": "みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。",
"onbProfileTitle": "プロフィールを設定してください",
"onbNicknameHint": "2〜20文字・他の人と同じでも大丈夫です",
"onbCountry": "国・地域",
"onbLanguage": "表示言語",
"onbReferral": "紹介コード(任意)",
"onbReferralHint": "友だちからもらったコードがあれば入力してください",
"onbArtistTitle": "好きなアーティストを選んでください",
"onbArtistBody": "最初に選んだアーティストが基本の惑星になります。複数のファンダムに加入できます。",
"onbArtistSearch": "アーティスト名で検索",
"onbArtistNone": "探しているアーティストがいません",
"onbPrimary": "基本",
"onbNebulaTag": "星雲",
"onbMembers": "{count}人",
"onbPlanetTitle": "惑星に加入するか、星雲を作りましょう",
"onbPlanetJoin": "選んだ惑星に加入する",
"onbPlanetCreate": "新しい星雲を作る",
"onbPermTitle": "アプリの権限について",
"onbPermBody": "以下の権限は該当機能を使うときに必要です。許可しなくてもアプリは利用できます。",
"onbPermPush": "通知",
"onbPermPushDesc": "イクリプス・HERO TIME・クエストのお知らせを受け取れます。",
"onbPermHealth": "ヘルスケアデータ(歩数)",
"onbPermHealthDesc": "ウォーキングクエストの歩数を数えます。初めて開始するときにリクエストします。",
"onbPermLocation": "位置情報",
"onbPermLocationDesc": "位置クエストの到着を確認します。初めて開始するときにリクエストします。",
"onbPermContinue": "続ける",
"coachZap": "タップしてPOPを私たちの惑星に送ろう",
"coachMy": "自分の記録はここで見られます",
"settingsWithdrawLinked": "連携中のアカウントを解除してから退会してください。"
} }
+52 -2
View File
@@ -1761,8 +1761,58 @@
"questCancelPublished": "공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.", "questCancelPublished": "공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.",
"questUnderReview": "운영 검수 중이에요. 승인되면 공개됩니다.", "questUnderReview": "운영 검수 중이에요. 승인되면 공개됩니다.",
"questModerationRejected": "운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.", "questModerationRejected": "운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.",
"@questModerationRejected": {"placeholders": {"reason": {"type": "String"}}}, "@questModerationRejected": {
"placeholders": {
"reason": {
"type": "String"
}
}
},
"questSubmittedForReview": "공개 요청했어요. 운영 검수 후 공개됩니다.", "questSubmittedForReview": "공개 요청했어요. 운영 검수 후 공개됩니다.",
"popReasonQuestFeeRefund": "퀘스트 수수료 반환", "popReasonQuestFeeRefund": "퀘스트 수수료 반환",
"questGoalSteps": "목표 걸음 수" "questGoalSteps": "목표 걸음 수",
"eclipseTapToJoin": "흔들기 어려우면 눌러서 참여",
"termsPrivacy": "개인정보 수집·이용 및 처리방침 동의 (필수)",
"onbSkip": "건너뛰기",
"onbIntro1Title": "우리 팬덤의 행성을 키워요",
"onbIntro1Body": "POP 을 모아 ZAP 으로 보내면\n팬덤 행성이 함께 성장합니다.",
"onbIntro2Title": "퀘스트로 응원해요",
"onbIntro2Body": "걷기 · 출석 · 스트리밍 퀘스트를 완료하고\nPOP 보상을 받으세요.",
"onbIntro3Title": "팬들과 함께 지켜요",
"onbIntro3Body": "이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.",
"onbProfileTitle": "프로필을 설정해 주세요",
"onbNicknameHint": "2~20자 · 다른 사람과 같아도 괜찮아요",
"onbCountry": "국가/지역",
"onbLanguage": "표시 언어",
"onbReferral": "추천인 코드 (선택)",
"onbReferralHint": "친구에게 받은 코드가 있으면 입력해 주세요",
"onbArtistTitle": "관심 아티스트를 선택해 주세요",
"onbArtistBody": "처음 고른 아티스트가 기본 행성이 됩니다. 여러 팬덤에 함께 가입할 수 있어요.",
"onbArtistSearch": "아티스트 이름 검색",
"onbArtistNone": "찾는 아티스트가 없어요",
"onbPrimary": "기본",
"onbNebulaTag": "성운",
"onbMembers": "{count}명",
"@onbMembers": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"onbPlanetTitle": "행성에 가입하거나 성운을 만들어 보세요",
"onbPlanetJoin": "선택한 행성 가입하기",
"onbPlanetCreate": "새 성운 만들기",
"onbPermTitle": "앱 권한 안내",
"onbPermBody": "아래 권한은 해당 기능을 쓸 때 필요합니다. 허용하지 않아도 앱을 이용할 수 있어요.",
"onbPermPush": "알림",
"onbPermPushDesc": "이클립스 · HERO TIME · 퀘스트 소식을 알려 드려요.",
"onbPermHealth": "건강 데이터(걸음 수)",
"onbPermHealthDesc": "걷기 퀘스트의 걸음 수를 셉니다. 걷기 퀘스트를 처음 시작할 때 요청해요.",
"onbPermLocation": "위치",
"onbPermLocationDesc": "위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.",
"onbPermContinue": "계속",
"coachZap": "눌러서 POP 을 우리 행성에 보내요",
"coachMy": "내 기록은 여기서 볼 수 있어요",
"settingsWithdrawLinked": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요."
} }
+222
View File
@@ -5101,6 +5101,228 @@ abstract class AppLocalizations {
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'목표 걸음 수'** /// **'목표 걸음 수'**
String get questGoalSteps; String get questGoalSteps;
/// No description provided for @eclipseTapToJoin.
///
/// In ko, this message translates to:
/// **'흔들기 어려우면 눌러서 참여'**
String get eclipseTapToJoin;
/// No description provided for @termsPrivacy.
///
/// In ko, this message translates to:
/// **'개인정보 수집·이용 및 처리방침 동의 (필수)'**
String get termsPrivacy;
/// No description provided for @onbSkip.
///
/// In ko, this message translates to:
/// **'건너뛰기'**
String get onbSkip;
/// No description provided for @onbIntro1Title.
///
/// In ko, this message translates to:
/// **'우리 팬덤의 행성을 키워요'**
String get onbIntro1Title;
/// No description provided for @onbIntro1Body.
///
/// In ko, this message translates to:
/// **'POP 을 모아 ZAP 으로 보내면\n팬덤 행성이 함께 성장합니다.'**
String get onbIntro1Body;
/// No description provided for @onbIntro2Title.
///
/// In ko, this message translates to:
/// **'퀘스트로 응원해요'**
String get onbIntro2Title;
/// No description provided for @onbIntro2Body.
///
/// In ko, this message translates to:
/// **'걷기 · 출석 · 스트리밍 퀘스트를 완료하고\nPOP 보상을 받으세요.'**
String get onbIntro2Body;
/// No description provided for @onbIntro3Title.
///
/// In ko, this message translates to:
/// **'팬들과 함께 지켜요'**
String get onbIntro3Title;
/// No description provided for @onbIntro3Body.
///
/// In ko, this message translates to:
/// **'이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.'**
String get onbIntro3Body;
/// No description provided for @onbProfileTitle.
///
/// In ko, this message translates to:
/// **'프로필을 설정해 주세요'**
String get onbProfileTitle;
/// No description provided for @onbNicknameHint.
///
/// In ko, this message translates to:
/// **'2~20자 · 다른 사람과 같아도 괜찮아요'**
String get onbNicknameHint;
/// No description provided for @onbCountry.
///
/// In ko, this message translates to:
/// **'국가/지역'**
String get onbCountry;
/// No description provided for @onbLanguage.
///
/// In ko, this message translates to:
/// **'표시 언어'**
String get onbLanguage;
/// No description provided for @onbReferral.
///
/// In ko, this message translates to:
/// **'추천인 코드 (선택)'**
String get onbReferral;
/// No description provided for @onbReferralHint.
///
/// In ko, this message translates to:
/// **'친구에게 받은 코드가 있으면 입력해 주세요'**
String get onbReferralHint;
/// No description provided for @onbArtistTitle.
///
/// In ko, this message translates to:
/// **'관심 아티스트를 선택해 주세요'**
String get onbArtistTitle;
/// No description provided for @onbArtistBody.
///
/// In ko, this message translates to:
/// **'처음 고른 아티스트가 기본 행성이 됩니다. 여러 팬덤에 함께 가입할 수 있어요.'**
String get onbArtistBody;
/// No description provided for @onbArtistSearch.
///
/// In ko, this message translates to:
/// **'아티스트 이름 검색'**
String get onbArtistSearch;
/// No description provided for @onbArtistNone.
///
/// In ko, this message translates to:
/// **'찾는 아티스트가 없어요'**
String get onbArtistNone;
/// No description provided for @onbPrimary.
///
/// In ko, this message translates to:
/// **'기본'**
String get onbPrimary;
/// No description provided for @onbNebulaTag.
///
/// In ko, this message translates to:
/// **'성운'**
String get onbNebulaTag;
/// No description provided for @onbMembers.
///
/// In ko, this message translates to:
/// **'{count}명'**
String onbMembers(String count);
/// No description provided for @onbPlanetTitle.
///
/// In ko, this message translates to:
/// **'행성에 가입하거나 성운을 만들어 보세요'**
String get onbPlanetTitle;
/// No description provided for @onbPlanetJoin.
///
/// In ko, this message translates to:
/// **'선택한 행성 가입하기'**
String get onbPlanetJoin;
/// No description provided for @onbPlanetCreate.
///
/// In ko, this message translates to:
/// **'새 성운 만들기'**
String get onbPlanetCreate;
/// No description provided for @onbPermTitle.
///
/// In ko, this message translates to:
/// **'앱 권한 안내'**
String get onbPermTitle;
/// No description provided for @onbPermBody.
///
/// In ko, this message translates to:
/// **'아래 권한은 해당 기능을 쓸 때 필요합니다. 허용하지 않아도 앱을 이용할 수 있어요.'**
String get onbPermBody;
/// No description provided for @onbPermPush.
///
/// In ko, this message translates to:
/// **'알림'**
String get onbPermPush;
/// No description provided for @onbPermPushDesc.
///
/// In ko, this message translates to:
/// **'이클립스 · HERO TIME · 퀘스트 소식을 알려 드려요.'**
String get onbPermPushDesc;
/// No description provided for @onbPermHealth.
///
/// In ko, this message translates to:
/// **'건강 데이터(걸음 수)'**
String get onbPermHealth;
/// No description provided for @onbPermHealthDesc.
///
/// In ko, this message translates to:
/// **'걷기 퀘스트의 걸음 수를 셉니다. 걷기 퀘스트를 처음 시작할 때 요청해요.'**
String get onbPermHealthDesc;
/// No description provided for @onbPermLocation.
///
/// In ko, this message translates to:
/// **'위치'**
String get onbPermLocation;
/// No description provided for @onbPermLocationDesc.
///
/// In ko, this message translates to:
/// **'위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.'**
String get onbPermLocationDesc;
/// No description provided for @onbPermContinue.
///
/// In ko, this message translates to:
/// **'계속'**
String get onbPermContinue;
/// No description provided for @coachZap.
///
/// In ko, this message translates to:
/// **'눌러서 POP 을 우리 행성에 보내요'**
String get coachZap;
/// No description provided for @coachMy.
///
/// In ko, this message translates to:
/// **'내 기록은 여기서 볼 수 있어요'**
String get coachMy;
/// No description provided for @settingsWithdrawLinked.
///
/// In ko, this message translates to:
/// **'연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'**
String get settingsWithdrawLinked;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+122
View File
@@ -2907,4 +2907,126 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get questGoalSteps => 'Step goal'; String get questGoalSteps => 'Step goal';
@override
String get eclipseTapToJoin => 'Can\'t shake? Tap to join';
@override
String get termsPrivacy =>
'Privacy Policy and Personal Data Collection (required)';
@override
String get onbSkip => 'Skip';
@override
String get onbIntro1Title => 'Grow your fandom\'s planet';
@override
String get onbIntro1Body =>
'Collect POP and send it with ZAP\nto grow your fandom planet together.';
@override
String get onbIntro2Title => 'Cheer with quests';
@override
String get onbIntro2Body =>
'Complete walking, attendance and streaming quests\nand earn POP rewards.';
@override
String get onbIntro3Title => 'Protect it with fellow fans';
@override
String get onbIntro3Body =>
'Stop the Eclipse together and support\nyour artist through campaigns.';
@override
String get onbProfileTitle => 'Set up your profile';
@override
String get onbNicknameHint => '2–20 characters · duplicates are allowed';
@override
String get onbCountry => 'Country/Region';
@override
String get onbLanguage => 'Display language';
@override
String get onbReferral => 'Referral code (optional)';
@override
String get onbReferralHint => 'Enter a code if a friend shared one';
@override
String get onbArtistTitle => 'Choose the artists you follow';
@override
String get onbArtistBody =>
'The first artist you pick becomes your main planet. You can join several fandoms.';
@override
String get onbArtistSearch => 'Search artist name';
@override
String get onbArtistNone => 'I can\'t find my artist';
@override
String get onbPrimary => 'Main';
@override
String get onbNebulaTag => 'Nebula';
@override
String onbMembers(String count) {
return '$count members';
}
@override
String get onbPlanetTitle => 'Join a planet or create a nebula';
@override
String get onbPlanetJoin => 'Join selected planets';
@override
String get onbPlanetCreate => 'Create a new nebula';
@override
String get onbPermTitle => 'App permissions';
@override
String get onbPermBody =>
'These permissions are needed only for the related features. You can use the app without them.';
@override
String get onbPermPush => 'Notifications';
@override
String get onbPermPushDesc => 'Get Eclipse, HERO TIME and quest updates.';
@override
String get onbPermHealth => 'Health data (steps)';
@override
String get onbPermHealthDesc =>
'Counts steps for walking quests. Requested when you first start one.';
@override
String get onbPermLocation => 'Location';
@override
String get onbPermLocationDesc =>
'Checks arrival for location quests. Requested when you first start one.';
@override
String get onbPermContinue => 'Continue';
@override
String get coachZap => 'Tap to send POP to our planet';
@override
String get coachMy => 'Your records are here';
@override
String get settingsWithdrawLinked =>
'Please unlink your connected accounts before deleting your account.';
} }
+124
View File
@@ -2922,4 +2922,128 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get questGoalSteps => 'Meta de pasos'; String get questGoalSteps => 'Meta de pasos';
@override
String get eclipseTapToJoin => '¿No puedes agitar? Toca para participar';
@override
String get termsPrivacy =>
'Política de privacidad y recopilación de datos personales (obligatorio)';
@override
String get onbSkip => 'Omitir';
@override
String get onbIntro1Title => 'Haz crecer el planeta de tu fandom';
@override
String get onbIntro1Body =>
'Reúne POP y envíalo con ZAP\npara que el planeta de tu fandom crezca.';
@override
String get onbIntro2Title => 'Apoya con misiones';
@override
String get onbIntro2Body =>
'Completa misiones de caminar, asistencia y streaming\ny gana POP.';
@override
String get onbIntro3Title => 'Protégelo junto a otros fans';
@override
String get onbIntro3Body =>
'Detengan juntos el Eclipse y apoyen\na su artista con campañas.';
@override
String get onbProfileTitle => 'Configura tu perfil';
@override
String get onbNicknameHint => '2–20 caracteres · se permiten duplicados';
@override
String get onbCountry => 'País/Región';
@override
String get onbLanguage => 'Idioma de visualización';
@override
String get onbReferral => 'Código de referido (opcional)';
@override
String get onbReferralHint =>
'Introduce el código si un amigo te lo compartió';
@override
String get onbArtistTitle => 'Elige a los artistas que sigues';
@override
String get onbArtistBody =>
'El primer artista que elijas será tu planeta principal. Puedes unirte a varios fandoms.';
@override
String get onbArtistSearch => 'Buscar nombre del artista';
@override
String get onbArtistNone => 'No encuentro a mi artista';
@override
String get onbPrimary => 'Principal';
@override
String get onbNebulaTag => 'Nebulosa';
@override
String onbMembers(String count) {
return '$count miembros';
}
@override
String get onbPlanetTitle => 'Únete a un planeta o crea una nebulosa';
@override
String get onbPlanetJoin => 'Unirse a los planetas elegidos';
@override
String get onbPlanetCreate => 'Crear una nebulosa nueva';
@override
String get onbPermTitle => 'Permisos de la app';
@override
String get onbPermBody =>
'Estos permisos solo se necesitan para las funciones relacionadas. Puedes usar la app sin ellos.';
@override
String get onbPermPush => 'Notificaciones';
@override
String get onbPermPushDesc =>
'Recibe avisos de Eclipse, HERO TIME y misiones.';
@override
String get onbPermHealth => 'Datos de salud (pasos)';
@override
String get onbPermHealthDesc =>
'Cuenta pasos para misiones de caminar. Se solicita al empezar la primera.';
@override
String get onbPermLocation => 'Ubicación';
@override
String get onbPermLocationDesc =>
'Comprueba la llegada en misiones de ubicación. Se solicita al empezar la primera.';
@override
String get onbPermContinue => 'Continuar';
@override
String get coachZap => 'Toca para enviar POP a nuestro planeta';
@override
String get coachMy => 'Aquí puedes ver tus registros';
@override
String get settingsWithdrawLinked =>
'Desvincula tus cuentas conectadas antes de eliminar tu cuenta.';
} }
+113
View File
@@ -2811,4 +2811,117 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get questGoalSteps => '目標歩数'; String get questGoalSteps => '目標歩数';
@override
String get eclipseTapToJoin => '振るのが難しければタップで参加';
@override
String get termsPrivacy => '個人情報の収集・利用およびプライバシーポリシーへの同意(必須)';
@override
String get onbSkip => 'スキップ';
@override
String get onbIntro1Title => '私たちのファンダムの惑星を育てよう';
@override
String get onbIntro1Body => 'POPを集めてZAPで送ると\nファンダムの惑星が一緒に成長します。';
@override
String get onbIntro2Title => 'クエストで応援しよう';
@override
String get onbIntro2Body => 'ウォーキング・出席・ストリーミングのクエストを達成して\nPOPを受け取りましょう。';
@override
String get onbIntro3Title => 'ファンと一緒に守ろう';
@override
String get onbIntro3Body => 'みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。';
@override
String get onbProfileTitle => 'プロフィールを設定してください';
@override
String get onbNicknameHint => '2〜20文字・他の人と同じでも大丈夫です';
@override
String get onbCountry => '国・地域';
@override
String get onbLanguage => '表示言語';
@override
String get onbReferral => '紹介コード(任意)';
@override
String get onbReferralHint => '友だちからもらったコードがあれば入力してください';
@override
String get onbArtistTitle => '好きなアーティストを選んでください';
@override
String get onbArtistBody => '最初に選んだアーティストが基本の惑星になります。複数のファンダムに加入できます。';
@override
String get onbArtistSearch => 'アーティスト名で検索';
@override
String get onbArtistNone => '探しているアーティストがいません';
@override
String get onbPrimary => '基本';
@override
String get onbNebulaTag => '星雲';
@override
String onbMembers(String count) {
return '$count人';
}
@override
String get onbPlanetTitle => '惑星に加入するか、星雲を作りましょう';
@override
String get onbPlanetJoin => '選んだ惑星に加入する';
@override
String get onbPlanetCreate => '新しい星雲を作る';
@override
String get onbPermTitle => 'アプリの権限について';
@override
String get onbPermBody => '以下の権限は該当機能を使うときに必要です。許可しなくてもアプリは利用できます。';
@override
String get onbPermPush => '通知';
@override
String get onbPermPushDesc => 'イクリプス・HERO TIME・クエストのお知らせを受け取れます。';
@override
String get onbPermHealth => 'ヘルスケアデータ(歩数)';
@override
String get onbPermHealthDesc => 'ウォーキングクエストの歩数を数えます。初めて開始するときにリクエストします。';
@override
String get onbPermLocation => '位置情報';
@override
String get onbPermLocationDesc => '位置クエストの到着を確認します。初めて開始するときにリクエストします。';
@override
String get onbPermContinue => '続ける';
@override
String get coachZap => 'タップしてPOPを私たちの惑星に送ろう';
@override
String get coachMy => '自分の記録はここで見られます';
@override
String get settingsWithdrawLinked => '連携中のアカウントを解除してから退会してください。';
} }
+114
View File
@@ -2814,4 +2814,118 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get questGoalSteps => '목표 걸음 수'; String get questGoalSteps => '목표 걸음 수';
@override
String get eclipseTapToJoin => '흔들기 어려우면 눌러서 참여';
@override
String get termsPrivacy => '개인정보 수집·이용 및 처리방침 동의 (필수)';
@override
String get onbSkip => '건너뛰기';
@override
String get onbIntro1Title => '우리 팬덤의 행성을 키워요';
@override
String get onbIntro1Body => 'POP 을 모아 ZAP 으로 보내면\n팬덤 행성이 함께 성장합니다.';
@override
String get onbIntro2Title => '퀘스트로 응원해요';
@override
String get onbIntro2Body => '걷기 · 출석 · 스트리밍 퀘스트를 완료하고\nPOP 보상을 받으세요.';
@override
String get onbIntro3Title => '팬들과 함께 지켜요';
@override
String get onbIntro3Body => '이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.';
@override
String get onbProfileTitle => '프로필을 설정해 주세요';
@override
String get onbNicknameHint => '2~20자 · 다른 사람과 같아도 괜찮아요';
@override
String get onbCountry => '국가/지역';
@override
String get onbLanguage => '표시 언어';
@override
String get onbReferral => '추천인 코드 (선택)';
@override
String get onbReferralHint => '친구에게 받은 코드가 있으면 입력해 주세요';
@override
String get onbArtistTitle => '관심 아티스트를 선택해 주세요';
@override
String get onbArtistBody => '처음 고른 아티스트가 기본 행성이 됩니다. 여러 팬덤에 함께 가입할 수 있어요.';
@override
String get onbArtistSearch => '아티스트 이름 검색';
@override
String get onbArtistNone => '찾는 아티스트가 없어요';
@override
String get onbPrimary => '기본';
@override
String get onbNebulaTag => '성운';
@override
String onbMembers(String count) {
return '$count명';
}
@override
String get onbPlanetTitle => '행성에 가입하거나 성운을 만들어 보세요';
@override
String get onbPlanetJoin => '선택한 행성 가입하기';
@override
String get onbPlanetCreate => '새 성운 만들기';
@override
String get onbPermTitle => '앱 권한 안내';
@override
String get onbPermBody => '아래 권한은 해당 기능을 쓸 때 필요합니다. 허용하지 않아도 앱을 이용할 수 있어요.';
@override
String get onbPermPush => '알림';
@override
String get onbPermPushDesc => '이클립스 · HERO TIME · 퀘스트 소식을 알려 드려요.';
@override
String get onbPermHealth => '건강 데이터(걸음 수)';
@override
String get onbPermHealthDesc => '걷기 퀘스트의 걸음 수를 셉니다. 걷기 퀘스트를 처음 시작할 때 요청해요.';
@override
String get onbPermLocation => '위치';
@override
String get onbPermLocationDesc =>
'위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.';
@override
String get onbPermContinue => '계속';
@override
String get coachZap => '눌러서 POP 을 우리 행성에 보내요';
@override
String get coachMy => '내 기록은 여기서 볼 수 있어요';
@override
String get settingsWithdrawLinked => '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.';
} }
+113
View File
@@ -2807,4 +2807,117 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get questGoalSteps => '目标步数'; String get questGoalSteps => '目标步数';
@override
String get eclipseTapToJoin => '不方便摇动?点击参与';
@override
String get termsPrivacy => '个人信息收集·使用及隐私政策(必选)';
@override
String get onbSkip => '跳过';
@override
String get onbIntro1Title => '一起培养我们粉丝团的星球';
@override
String get onbIntro1Body => '收集POP并通过ZAP发送,\n粉丝团星球就会一起成长。';
@override
String get onbIntro2Title => '用任务来应援';
@override
String get onbIntro2Body => '完成步行·签到·播放任务,\n领取POP奖励。';
@override
String get onbIntro3Title => '和粉丝们一起守护';
@override
String get onbIntro3Body => '一起阻止日食,通过活动\n为艺人应援。';
@override
String get onbProfileTitle => '请设置个人资料';
@override
String get onbNicknameHint => '2~20个字符·可与他人重复';
@override
String get onbCountry => '国家/地区';
@override
String get onbLanguage => '显示语言';
@override
String get onbReferral => '推荐码(选填)';
@override
String get onbReferralHint => '如有朋友分享的推荐码请输入';
@override
String get onbArtistTitle => '请选择关注的艺人';
@override
String get onbArtistBody => '第一个选择的艺人将成为默认星球。可以同时加入多个粉丝团。';
@override
String get onbArtistSearch => '搜索艺人名称';
@override
String get onbArtistNone => '找不到我的艺人';
@override
String get onbPrimary => '默认';
@override
String get onbNebulaTag => '星云';
@override
String onbMembers(String count) {
return '$count人';
}
@override
String get onbPlanetTitle => '加入星球或创建星云';
@override
String get onbPlanetJoin => '加入所选星球';
@override
String get onbPlanetCreate => '创建新星云';
@override
String get onbPermTitle => '应用权限说明';
@override
String get onbPermBody => '以下权限仅在使用相关功能时需要。不允许也可以使用应用。';
@override
String get onbPermPush => '通知';
@override
String get onbPermPushDesc => '接收日食·HERO TIME·任务消息。';
@override
String get onbPermHealth => '健康数据(步数)';
@override
String get onbPermHealthDesc => '用于统计步行任务的步数。首次开始时请求。';
@override
String get onbPermLocation => '位置';
@override
String get onbPermLocationDesc => '用于确认位置任务的到达。首次开始时请求。';
@override
String get onbPermContinue => '继续';
@override
String get coachZap => '点击将POP发送到我们的星球';
@override
String get coachMy => '我的记录可以在这里查看';
@override
String get settingsWithdrawLinked => '请先解除关联账号后再注销账号。';
} }
+38 -1
View File
@@ -830,5 +830,42 @@
"questModerationRejected": "审核未通过:{reason}。已退还存入的POP和手续费。", "questModerationRejected": "审核未通过:{reason}。已退还存入的POP和手续费。",
"questSubmittedForReview": "已提交公开申请,审核后公开。", "questSubmittedForReview": "已提交公开申请,审核后公开。",
"popReasonQuestFeeRefund": "任务手续费退还", "popReasonQuestFeeRefund": "任务手续费退还",
"questGoalSteps": "目标步数" "questGoalSteps": "目标步数",
"eclipseTapToJoin": "不方便摇动?点击参与",
"termsPrivacy": "个人信息收集·使用及隐私政策(必选)",
"onbSkip": "跳过",
"onbIntro1Title": "一起培养我们粉丝团的星球",
"onbIntro1Body": "收集POP并通过ZAP发送,\n粉丝团星球就会一起成长。",
"onbIntro2Title": "用任务来应援",
"onbIntro2Body": "完成步行·签到·播放任务,\n领取POP奖励。",
"onbIntro3Title": "和粉丝们一起守护",
"onbIntro3Body": "一起阻止日食,通过活动\n为艺人应援。",
"onbProfileTitle": "请设置个人资料",
"onbNicknameHint": "2~20个字符·可与他人重复",
"onbCountry": "国家/地区",
"onbLanguage": "显示语言",
"onbReferral": "推荐码(选填)",
"onbReferralHint": "如有朋友分享的推荐码请输入",
"onbArtistTitle": "请选择关注的艺人",
"onbArtistBody": "第一个选择的艺人将成为默认星球。可以同时加入多个粉丝团。",
"onbArtistSearch": "搜索艺人名称",
"onbArtistNone": "找不到我的艺人",
"onbPrimary": "默认",
"onbNebulaTag": "星云",
"onbMembers": "{count}人",
"onbPlanetTitle": "加入星球或创建星云",
"onbPlanetJoin": "加入所选星球",
"onbPlanetCreate": "创建新星云",
"onbPermTitle": "应用权限说明",
"onbPermBody": "以下权限仅在使用相关功能时需要。不允许也可以使用应用。",
"onbPermPush": "通知",
"onbPermPushDesc": "接收日食·HERO TIME·任务消息。",
"onbPermHealth": "健康数据(步数)",
"onbPermHealthDesc": "用于统计步行任务的步数。首次开始时请求。",
"onbPermLocation": "位置",
"onbPermLocationDesc": "用于确认位置任务的到达。首次开始时请求。",
"onbPermContinue": "继续",
"coachZap": "点击将POP发送到我们的星球",
"coachMy": "我的记录可以在这里查看",
"settingsWithdrawLinked": "请先解除关联账号后再注销账号。"
} }
+111
View File
@@ -394,6 +394,12 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String campaignYearMonth(String year, String month) => _fill('campaignYearMonth', {'year': year, 'month': month}) ?? _base.campaignYearMonth(year, month); String campaignYearMonth(String year, String month) => _fill('campaignYearMonth', {'year': year, 'month': month}) ?? _base.campaignYearMonth(year, month);
@override
String get coachMy => _remote['coachMy'] ?? _base.coachMy;
@override
String get coachZap => _remote['coachZap'] ?? _base.coachZap;
@override @override
String get commentDeleteConfirm => _remote['commentDeleteConfirm'] ?? _base.commentDeleteConfirm; String get commentDeleteConfirm => _remote['commentDeleteConfirm'] ?? _base.commentDeleteConfirm;
@@ -664,6 +670,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get eclipseSuccessTitle => _remote['eclipseSuccessTitle'] ?? _base.eclipseSuccessTitle; String get eclipseSuccessTitle => _remote['eclipseSuccessTitle'] ?? _base.eclipseSuccessTitle;
@override
String get eclipseTapToJoin => _remote['eclipseTapToJoin'] ?? _base.eclipseTapToJoin;
@override @override
String get eclipseTimeLeft => _remote['eclipseTimeLeft'] ?? _base.eclipseTimeLeft; String get eclipseTimeLeft => _remote['eclipseTimeLeft'] ?? _base.eclipseTimeLeft;
@@ -1321,6 +1330,102 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get notifTargetQuest => _remote['notifTargetQuest'] ?? _base.notifTargetQuest; String get notifTargetQuest => _remote['notifTargetQuest'] ?? _base.notifTargetQuest;
@override
String get onbArtistBody => _remote['onbArtistBody'] ?? _base.onbArtistBody;
@override
String get onbArtistNone => _remote['onbArtistNone'] ?? _base.onbArtistNone;
@override
String get onbArtistSearch => _remote['onbArtistSearch'] ?? _base.onbArtistSearch;
@override
String get onbArtistTitle => _remote['onbArtistTitle'] ?? _base.onbArtistTitle;
@override
String get onbCountry => _remote['onbCountry'] ?? _base.onbCountry;
@override
String get onbIntro1Body => _remote['onbIntro1Body'] ?? _base.onbIntro1Body;
@override
String get onbIntro1Title => _remote['onbIntro1Title'] ?? _base.onbIntro1Title;
@override
String get onbIntro2Body => _remote['onbIntro2Body'] ?? _base.onbIntro2Body;
@override
String get onbIntro2Title => _remote['onbIntro2Title'] ?? _base.onbIntro2Title;
@override
String get onbIntro3Body => _remote['onbIntro3Body'] ?? _base.onbIntro3Body;
@override
String get onbIntro3Title => _remote['onbIntro3Title'] ?? _base.onbIntro3Title;
@override
String get onbLanguage => _remote['onbLanguage'] ?? _base.onbLanguage;
@override
String onbMembers(String count) => _fill('onbMembers', {'count': count}) ?? _base.onbMembers(count);
@override
String get onbNebulaTag => _remote['onbNebulaTag'] ?? _base.onbNebulaTag;
@override
String get onbNicknameHint => _remote['onbNicknameHint'] ?? _base.onbNicknameHint;
@override
String get onbPermBody => _remote['onbPermBody'] ?? _base.onbPermBody;
@override
String get onbPermContinue => _remote['onbPermContinue'] ?? _base.onbPermContinue;
@override
String get onbPermHealth => _remote['onbPermHealth'] ?? _base.onbPermHealth;
@override
String get onbPermHealthDesc => _remote['onbPermHealthDesc'] ?? _base.onbPermHealthDesc;
@override
String get onbPermLocation => _remote['onbPermLocation'] ?? _base.onbPermLocation;
@override
String get onbPermLocationDesc => _remote['onbPermLocationDesc'] ?? _base.onbPermLocationDesc;
@override
String get onbPermPush => _remote['onbPermPush'] ?? _base.onbPermPush;
@override
String get onbPermPushDesc => _remote['onbPermPushDesc'] ?? _base.onbPermPushDesc;
@override
String get onbPermTitle => _remote['onbPermTitle'] ?? _base.onbPermTitle;
@override
String get onbPlanetCreate => _remote['onbPlanetCreate'] ?? _base.onbPlanetCreate;
@override
String get onbPlanetJoin => _remote['onbPlanetJoin'] ?? _base.onbPlanetJoin;
@override
String get onbPlanetTitle => _remote['onbPlanetTitle'] ?? _base.onbPlanetTitle;
@override
String get onbPrimary => _remote['onbPrimary'] ?? _base.onbPrimary;
@override
String get onbProfileTitle => _remote['onbProfileTitle'] ?? _base.onbProfileTitle;
@override
String get onbReferral => _remote['onbReferral'] ?? _base.onbReferral;
@override
String get onbReferralHint => _remote['onbReferralHint'] ?? _base.onbReferralHint;
@override
String get onbSkip => _remote['onbSkip'] ?? _base.onbSkip;
@override @override
String get openSettings => _remote['openSettings'] ?? _base.openSettings; String get openSettings => _remote['openSettings'] ?? _base.openSettings;
@@ -2245,6 +2350,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater; String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater;
@override
String get settingsWithdrawLinked => _remote['settingsWithdrawLinked'] ?? _base.settingsWithdrawLinked;
@override @override
String shakeCombo(String n) => _fill('shakeCombo', {'n': n}) ?? _base.shakeCombo(n); String shakeCombo(String n) => _fill('shakeCombo', {'n': n}) ?? _base.shakeCombo(n);
@@ -2377,6 +2485,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get termsMarketing => _remote['termsMarketing'] ?? _base.termsMarketing; String get termsMarketing => _remote['termsMarketing'] ?? _base.termsMarketing;
@override
String get termsPrivacy => _remote['termsPrivacy'] ?? _base.termsPrivacy;
@override @override
String get termsService => _remote['termsService'] ?? _base.termsService; String get termsService => _remote['termsService'] ?? _base.termsService;
+23 -2
View File
@@ -1,14 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart'; import 'app.dart';
import 'core/firebase/firebase_boot.dart'; import 'core/firebase/firebase_boot.dart';
import 'features/auth/auth_repository.dart'; import 'features/auth/auth_repository.dart';
import 'features/auth/firebase_auth_session.dart'; import 'features/auth/firebase_auth_session.dart';
import 'features/auth/onboarding_data.dart';
/// 소개 3장(온보딩 ①)을 이 기기에서 넘겼는가.
const _introSeenKey = 'onboarding.introSeen';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고. // 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
await FirebaseBoot.init(); await FirebaseBoot.init();
final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null; final live = FirebaseBoot.isLive;
runApp(FancakeApp(auth: AuthRepository(session: session))); final session = live ? FirebaseAuthSession() : null;
var introSeen = true;
try {
introSeen =
(await SharedPreferences.getInstance()).getBool(_introSeenKey) ?? false;
} catch (_) {}
runApp(
FancakeApp(
auth: AuthRepository(
session: session,
needsOnboarding: live ? needsOnboardingFirestore : null,
introPending: !introSeen,
onIntroDone: () async => (await SharedPreferences.getInstance())
.setBool(_introSeenKey, true),
),
),
);
} }
+61 -9
View File
@@ -180,10 +180,7 @@ void main() {
testWidgets('행성/성운 칸은 가입 목록을 팬덤 stage 그림으로 그린다', (tester) async { testWidgets('행성/성운 칸은 가입 목록을 팬덤 stage 그림으로 그린다', (tester) async {
await pumpWide( await pumpWide(
tester, tester,
MyScreen( MyScreen(repository: SampleMyRepository(), galaxy: _OneNebula()),
repository: SampleMyRepository(),
galaxy: _OneNebula(),
),
); );
await tester.pump(); await tester.pump();
final images = tester final images = tester
@@ -191,7 +188,10 @@ void main() {
.map((i) => (i.image as AssetImage).assetName) .map((i) => (i.image as AssetImage).assetName)
.toList(); .toList();
expect(images, contains('assets/figma/nebula-card.png')); expect(images, contains('assets/figma/nebula-card.png'));
expect(images.where((a) => a.startsWith('assets/figma/my-planet-')), isEmpty); expect(
images.where((a) => a.startsWith('assets/figma/my-planet-')),
isEmpty,
);
expect(find.bySemanticsLabel('새 성운'), findsOneWidget); expect(find.bySemanticsLabel('새 성운'), findsOneWidget);
}); });
@@ -233,8 +233,12 @@ void main() {
return null; return null;
}, },
); );
addTearDown(() => tester.binding.defaultBinaryMessenger addTearDown(
.setMockMethodCallHandler(SystemChannels.platform, null)); () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
expect(await shareProfile(profile, '문구 복사'), isTrue); expect(await shareProfile(profile, '문구 복사'), isTrue);
expect(clip, profileShareText(profile)); expect(clip, profileShareText(profile));
}); });
@@ -471,6 +475,41 @@ void main() {
expect(find.text('취소'), findsOneWidget); expect(find.text('취소'), findsOneWidget);
}); });
Future<void> confirmWithdraw(WidgetTester tester) async {
await tester.scrollUntilVisible(find.text('서비스 탈퇴'), 200);
await tester.ensureVisible(find.text('서비스 탈퇴'));
await tester.pumpAndSettle();
await tester.tap(find.text('서비스 탈퇴'));
await tester.pumpAndSettle();
await tester.tap(find.text('서비스 탈퇴').last);
await tester.pumpAndSettle();
}
testWidgets('SNS 연동이 남아 있으면 비정상 탈퇴 안내만 (554:24908)', (tester) async {
final repo = _WithdrawRepo();
await pumpWide(tester, SettingsScreen(repository: repo));
await tester.pump();
await confirmWithdraw(tester);
expect(find.text('연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'), findsOneWidget);
expect(repo.withdrawn, isFalse);
});
testWidgets('연동이 하나뿐이면 서버 탈퇴를 부른다 (554:24885)', (tester) async {
final repo = _WithdrawRepo();
await pumpWide(
tester,
SettingsScreen(
repository: repo,
linker: SampleAccountLinker(
seed: const {AuthProviderKind.google: 'fan@gmail.com'},
),
),
);
await tester.pump();
await confirmWithdraw(tester);
expect(repo.withdrawn, isTrue);
});
testWidgets('SNS 연동 라벨은 제공자 이름만 쓴다 — 「LINE으」 잘림 없음', (tester) async { testWidgets('SNS 연동 라벨은 제공자 이름만 쓴다 — 「LINE으」 잘림 없음', (tester) async {
await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await pumpWide(tester, SettingsScreen(repository: SampleMyRepository()));
await tester.pump(); await tester.pump();
@@ -1112,9 +1151,22 @@ class _OneNebula extends SampleGalaxyRepository {
myPop: 0, myPop: 0,
memberships: [ memberships: [
PlanetNode( PlanetNode(
id: 'n1', name: '새 성운', rank: 0, fpi: 0, members: 1, online: 0, id: 'n1',
isMine: true, stage: 0, name: '새 성운',
rank: 0,
fpi: 0,
members: 1,
online: 0,
isMine: true,
stage: 0,
), ),
], ],
); );
} }
class _WithdrawRepo extends SampleMyRepository {
bool withdrawn = false;
@override
Future<void> withdraw() async => withdrawn = true;
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:fancake/app.dart';
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/auth/login_screen.dart';
import 'package:fancake/features/auth/onboarding_data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 온보딩 6단계(10/3 통합회신 1항) · 「로그인」 버튼 신규 계정의 약관 우회 차단(E-05).
void main() {
setUp(() {
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '온보딩 테스트');
SharedPreferences.setMockInitialValues({});
});
Future<(AuthRepository, SampleOnboardingRepository)> boot(
WidgetTester tester, {
required bool needs,
bool intro = false,
}) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
final repos = Repositories(live: false);
final auth = AuthRepository(
providers: [_Provider()],
releaseMode: true,
needsOnboarding: (_) async => needs,
introPending: intro,
);
addTearDown(auth.dispose);
await tester.pumpWidget(FancakeApp(auth: auth, repositories: repos));
await tester.pumpAndSettle();
return (auth, repos.onboarding as SampleOnboardingRepository);
}
testWidgets('「로그인」으로 들어온 신규 계정도 약관부터 — 셸로 가지 않는다', (tester) async {
final (auth, _) = await boot(tester, needs: true);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
expect(auth.onboarding, OnboardingGate.required);
expect(find.byType(NavigationBar), findsNothing);
expect(find.text('개인정보 수집·이용 및 처리방침 동의 (필수)'), findsOneWidget);
});
testWidgets('약관 동의가 있는 계정은 바로 셸', (tester) async {
final (auth, _) = await boot(tester, needs: false);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
});
testWidgets('약관 → 프로필 → 관심 아티스트 → 행성 가입 → 권한 → 가입 완료 → 셸', (tester) async {
final (auth, repo) = await boot(tester, needs: true);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
// ② 약관 — 필수만 동의(마케팅 미동의).
for (final label in [
'서비스 이용약관 (필수)',
'개인정보 수집·이용 및 처리방침 동의 (필수)',
'위치기반 서비스 이용약관 (필수)',
'만 14세 이상입니다. (필수)',
]) {
await tester.tap(find.text(label));
}
await tester.pump();
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
expect(repo.agreed, {'service', 'privacy', 'location', 'age'});
// ③ 프로필
await tester.enterText(find.byType(TextField).first, '우주팬');
await tester.enterText(find.byType(TextField).last, 'ABCDEFGHJK');
await tester.pump();
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
expect(repo.profile?.nickname, '우주팬');
expect(repo.profile?.country, isNotEmpty);
expect(repo.referral, 'ABCDEFGHJK');
// ④ 관심 아티스트 — 최소 1개 전에는 다음이 잠겨 있다.
final next = find.widgetWithText(FilledButton, '다음');
expect(tester.widget<FilledButton>(next).onPressed, isNull);
await tester.tap(find.text('NewJeans'));
await tester.tap(find.text('BTS'));
await tester.pump();
await tester.tap(next);
await tester.pumpAndSettle();
// ⑤ 행성 가입 — 고른 순서(첫 번째가 대표).
await tester.tap(find.text('선택한 행성 가입하기'));
await tester.pumpAndSettle();
expect(repo.joined, ['newjeans', 'bts']);
// ⑥ 권한 안내 → 가입 완료(182:29344) → 셸
expect(find.text('앱 권한 안내'), findsOneWidget);
await tester.tap(find.text('계속'));
await tester.pumpAndSettle();
expect(find.text('회원가입이 완료되었습니다'), findsOneWidget);
await tester.tap(find.text('시작'));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
expect(
(await SharedPreferences.getInstance()).getBool('coach.pending'),
isNotNull,
);
});
testWidgets('가입 모드에서 받은 약관은 다시 묻지 않고 기록만 한다', (tester) async {
final (auth, repo) = await boot(tester, needs: true);
auth.pendingConsent = {
'service',
'privacy',
'location',
'age',
'marketing',
};
await auth.signIn(SocialProvider.google, signUp: true);
await tester.pumpAndSettle();
expect(repo.agreed, contains('marketing'));
expect(find.text('프로필을 설정해 주세요'), findsOneWidget);
});
testWidgets('첫 실행은 소개 3장 — 건너뛰기를 눌러도 로그인 화면', (tester) async {
final (auth, _) = await boot(tester, needs: false, intro: true);
expect(find.text('우리 팬덤의 행성을 키워요'), findsOneWidget);
await tester.tap(find.text('건너뛰기'));
await tester.pumpAndSettle();
expect(auth.introPending, isFalse);
expect(find.byKey(loginLogoKey), findsOneWidget);
});
}
final class _Provider implements SocialAuthProvider {
@override
AuthProviderKind get provider => SocialProvider.google;
@override
bool get isLive => true;
@override
List<String> get missingConfig => const [];
@override
Future<AuthOutcome> signIn() async => const AuthSucceeded(
AuthAccount(uid: 'onb-user', provider: SocialProvider.google),
);
@override
Future<void> signOut() async {}
}