From 55424bd4a2bc27ee45675c6068205ba56baff27f Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 22:48:25 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EB=8F=99=EC=9D=98=20=EA=B8=B0=EB=A1=9D=20=C2=B7=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=83=88=ED=87=B4=20=C2=B7=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=206=EB=8B=A8=EA=B3=84=20=C2=B7=20Coach=20Mar?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/account/account.ts | 117 ++- app/firebase/functions/src/account/kakao.ts | 12 +- .../functions/src/account/referral.ts | 18 +- app/firebase/functions/src/core/config.ts | 3 - app/firebase/functions/src/core/errors.ts | 3 + app/firebase/functions/src/index.ts | 2 +- .../functions/test/account_onboarding.test.ts | 127 +++ app/firebase/functions/test/referral.test.ts | 6 +- app/lib/app.dart | 6 +- app/lib/core/data/repositories.dart | 6 + app/lib/core/router/app_router.dart | 54 +- app/lib/features/auth/auth_repository.dart | 74 +- app/lib/features/auth/login_screen.dart | 10 +- app/lib/features/auth/onboarding_data.dart | 171 +++++ app/lib/features/auth/onboarding_screens.dart | 720 ++++++++++++++++++ app/lib/features/auth/signup_screens.dart | 36 +- app/lib/features/home/coach_mark.dart | 161 ++++ app/lib/features/home/home_screen.dart | 7 + app/lib/features/my/my_data.dart | 6 + .../features/my/my_repository_firestore.dart | 6 +- app/lib/features/my/settings_screen.dart | 41 +- app/lib/l10n/app_en.arb | 39 +- app/lib/l10n/app_es.arb | 39 +- app/lib/l10n/app_ja.arb | 39 +- app/lib/l10n/app_ko.arb | 54 +- app/lib/l10n/app_localizations.dart | 222 ++++++ app/lib/l10n/app_localizations_en.dart | 122 +++ app/lib/l10n/app_localizations_es.dart | 124 +++ app/lib/l10n/app_localizations_ja.dart | 113 +++ app/lib/l10n/app_localizations_ko.dart | 114 +++ app/lib/l10n/app_localizations_zh.dart | 113 +++ app/lib/l10n/app_zh.arb | 39 +- app/lib/l10n/remote_app_localizations.dart | 111 +++ app/lib/main.dart | 25 +- app/test/f6_screens_test.dart | 70 +- app/test/onboarding_test.dart | 151 ++++ 36 files changed, 2884 insertions(+), 77 deletions(-) create mode 100644 app/firebase/functions/test/account_onboarding.test.ts create mode 100644 app/lib/features/auth/onboarding_data.dart create mode 100644 app/lib/features/auth/onboarding_screens.dart create mode 100644 app/lib/features/home/coach_mark.dart create mode 100644 app/test/onboarding_test.dart diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index a9a09f2..7e25121 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -19,13 +19,19 @@ import { ERR, fail } from '../core/errors.js'; */ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { const db = getFirestore(); - const batch = db.batch(); - - batch.set(db.doc(`users/${user.uid}`), { + const userRef = db.doc(`users/${user.uid}`); + const walletRef = db.doc(`users/${user.uid}/wallet/main`); + // 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」. + // Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는 + // 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다. + const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null; + const defaults: Record = { uid: user.uid, displayName: user.displayName ?? '팬우주 여행자', photoUrl: user.photoURL ?? null, - countryCode: 'KR', + // 국가는 정하지 않는다 — 온보딩 프로필 단계가 기기 지역을 기본값으로 받아 쓴다 + // (해석 확인 Q-OB-02 A 「프로필 = 닉네임 · 국가 · 표시 언어」). 비어 있으면 국가 랭킹은 `ZZ`. + email, locale: 'ko', fandomId: '', authProviders: user.providerData.map((p) => p.providerId), @@ -35,18 +41,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { notificationPrefs: {}, createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), - }, { merge: true }); - - batch.set(db.doc(`users/${user.uid}/wallet/main`), { + }; + const wallet: Record = { activityBalance: 0, supportBalance: 0, pendingActivity: 0, reconcileStatus: 'ok', createdAt: 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, have: Record | 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). */ @@ -434,6 +447,92 @@ export const fnConsentRecord = onCall(async (req) => { 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)?.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` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다. * diff --git a/app/firebase/functions/src/account/kakao.ts b/app/firebase/functions/src/account/kakao.ts index 346cd4f..22ddad6 100644 --- a/app/firebase/functions/src/account/kakao.ts +++ b/app/firebase/functions/src/account/kakao.ts @@ -1,4 +1,5 @@ import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; import { defineString } from 'firebase-functions/params'; import { onCall } from 'firebase-functions/v2/https'; @@ -96,7 +97,11 @@ function linkInfo(p: KakaoProfile) { export async function resolveKakaoUid(p: KakaoProfile): Promise { const existing = await ownerOf(p.id); if (existing) return existing; - const uid = kakaoUidOf(p.id); + // 탈퇴한 계정의 문서(원장 보존)가 `kakao:` 에 남아 있으면 같은 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(); try { await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) }); @@ -139,5 +144,10 @@ export const fnAuthKakao = onCall(async (req) => { return { linked: true }; } 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) }; }); diff --git a/app/firebase/functions/src/account/referral.ts b/app/firebase/functions/src/account/referral.ts index 134824d..b88dce4 100644 --- a/app/firebase/functions/src/account/referral.ts +++ b/app/firebase/functions/src/account/referral.ts @@ -9,7 +9,6 @@ import { callerUid, optionalStr, str } from '../core/callable.js'; import { loadPolicy } from '../core/config.js'; import { ERR, fail } from '../core/errors.js'; import { deviceHashOf } from '../core/ids.js'; -import { dayKey } from '../core/time.js'; import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.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명의 보상 상태. * - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기 * - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음 - * - `capped`: 추천인의 하루 상한(`referralDailyCapPerReferrer`) 초과 — 지급 없음 + * - `capped`: (폐지) 추천인 하루 상한 초과 — 10/3 통합회신 7항으로 상한 삭제. 이전 기록 해석용으로만 남긴다 * - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정 * - `paid`: 확정 · FPI REFERRAL 1 단위 * - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제 @@ -110,18 +109,19 @@ export const fnReferralAttribute = onCall(async (req) => { // 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다. 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 lockRef = db.doc(`referralsByUser/${uid}`); - const capRef = db.doc(`referralDaily/${referrerUid}_${dayKey(new Date())}`); let reward: ReferralReward = 'pending'; try { await db.runTransaction(async (tx) => { // 동시 호출의 둘째는 잠금 문서 `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(db.doc(`referralsByUser/${referrerUid}`)), tx.get(capRef), + tx.get(db.doc(`referralsByUser/${referrerUid}`)), // 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다. 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) { fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`); } - const today = (cap.get('count') as number | undefined) ?? 0; - reward = !done.empty ? 'ineligible' : today >= referralDailyCapPerReferrer ? 'capped' : 'pending'; - if (reward === 'pending') { - tx.set(capRef, { count: today + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true }); - } + reward = done.empty ? 'pending' : 'ineligible'; tx.create(lockRef, { attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(), }); diff --git a/app/firebase/functions/src/core/config.ts b/app/firebase/functions/src/core/config.ts index 8cf8084..9705e1e 100644 --- a/app/firebase/functions/src/core/config.ts +++ b/app/firebase/functions/src/core/config.ts @@ -114,8 +114,6 @@ export interface PolicyConfig { /** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */ referralReferrerPop: number; referralReferredPop: number; - /** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */ - referralDailyCapPerReferrer: number; /** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */ referralAttachDays: number; /** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */ @@ -223,7 +221,6 @@ export const DEFAULT_POLICY: PolicyConfig = { liveWindowMinutes: 5, referralReferrerPop: 100, referralReferredPop: 20, - referralDailyCapPerReferrer: 5, referralAttachDays: 7, timeAttackMaxMinutes: 1440, // 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명). diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index 7fc7d1d..577b204 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -40,6 +40,8 @@ export const ERR = { // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다. authCredentialInUse: 'E-AUTH-08', authAlreadyLinked: 'E-AUTH-09', + // 서비스 탈퇴 — SNS 연동이 남아 있으면 「비정상 탈퇴」(Figma `554:24885` · `554:24908`). + withdrawLinked: 'E-AUTH-14', // 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다. trnSelfMark: 'E-TRN-01', trnDailyCap: 'E-TRN-02', @@ -84,6 +86,7 @@ const STATUS: Record = { 'E-AUTH-03': 'permission-denied', 'E-AUTH-08': 'already-exists', 'E-AUTH-09': 'already-exists', + 'E-AUTH-14': 'failed-precondition', 'E-TRN-01': 'permission-denied', 'E-TRN-02': 'resource-exhausted', 'E-TRN-03': 'not-found', diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 8a495d8..8c56f1e 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -11,7 +11,7 @@ export { } from './quest/quest.js'; export { fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate, - fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole, + fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnSignupConsent, fnAccountWithdraw, fnAdminSetRole, } from './account/account.js'; export { fnAuthKakao } from './account/kakao.js'; export { diff --git a/app/firebase/functions/test/account_onboarding.test.ts b/app/firebase/functions/test/account_onboarding.test.ts new file mode 100644 index 0000000..dcdb0cd --- /dev/null +++ b/app/firebase/functions/test/account_onboarding.test.ts @@ -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[0]; +const as = (uid: string, data: Record) => ({ data, auth: { uid, token: {} } }) as CallReq; +const codeOf = async (p: Promise) => { + 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 = {}) => + (fnAuthOnCreate as unknown as { run: (u: unknown) => Promise }) + .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[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[0]); + expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com'); + }); + + it('탈퇴한 kakao: 문서가 있으면 새 UID 로 만든다', async () => { + stubKakao(); + await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).set({ status: 'withdrawn' }); + const { token } = await fnAuthKakao.run({ data: { accessToken: 'good' } } as Parameters[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); + }); +}); diff --git a/app/firebase/functions/test/referral.test.ts b/app/firebase/functions/test/referral.test.ts index c0b1f80..d808f33 100644 --- a/app/firebase/functions/test/referral.test.ts +++ b/app/firebase/functions/test/referral.test.ts @@ -93,13 +93,11 @@ describe('귀속 제한', () => { 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 }); const codeA = await inviteCodeOf(A); expect((await attribute(B, codeA)).reward).toBe('pending'); - expect((await attribute(C, codeA)).reward).toBe('capped'); - await complete(C); - expect((await wallet(A)).pendingActivity ?? 0).toBe(0); + expect((await attribute(C, codeA)).reward).toBe('pending'); }); it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => { diff --git a/app/lib/app.dart b/app/lib/app.dart index 48fa692..91103b7 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -169,9 +169,9 @@ class _FancakeAppState extends State with WidgetsBindingObserver { } Future _startPush(PushMessaging push) async { - // 권한을 먼저 묻는다. 거부해도 나머지는 계속한다 — 토큰과 구독은 사용자가 - // 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다. - await push.requestPermission(); + // 알림 권한은 여기서 묻지 않는다 — 온보딩 ⑥ 권한 안내가 쓰임을 설명한 뒤 묻는다 + // (FR-CMN-08 · 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은 + // 사용자가 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다. await push.syncToken(); // 팬덤 ID 는 PushMessaging 이 사용자 문서에서 직접 읽는다. diff --git a/app/lib/core/data/repositories.dart b/app/lib/core/data/repositories.dart index 55b2f6e..64b0bf0 100644 --- a/app/lib/core/data/repositories.dart +++ b/app/lib/core/data/repositories.dart @@ -1,3 +1,4 @@ +import '../../features/auth/onboarding_data.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'dart:async'; @@ -66,6 +67,11 @@ class Repositories { ? FirestoreInsideRepository(backend) : const SampleInsideRepository(); + /// 온보딩 6단계(약관 · 프로필 · 관심 아티스트 · 행성 가입). + late final OnboardingRepository onboarding = _live + ? FirestoreOnboardingRepository(backend) + : SampleOnboardingRepository(); + late final MyRepository my = _live ? FirestoreMyRepository(backend) : SampleMyRepository(); diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index c72aeea..e785f23 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -7,8 +7,10 @@ import '../theme/tokens.dart'; import '../ui/app_icons.dart'; import '../../features/auth/auth_repository.dart'; import '../../features/auth/login_screen.dart'; +import '../../features/auth/onboarding_screens.dart'; import '../../features/auth/signup_screens.dart'; import '../../features/galaxy/galaxy_home_screen.dart'; +import '../../features/home/coach_mark.dart'; import '../../features/home/home_screen.dart'; import '../../features/inside/inside_screen.dart'; import '../../features/my/my_screen.dart'; @@ -79,11 +81,15 @@ List visibleTabs(FeatureFlags flags) => [ const String loginPath = '/login'; 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 · -/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다. +/// 온보딩 6단계(10/3 통합회신 1항): ① 소개 3장([introPath], 첫 실행 · 로그인 전) → 로그인 → +/// ②~⑥ 약관 · 프로필 · 관심 아티스트 · 행성 가입/성운 생성 · 권한 안내([onboardingPath]) → +/// 가입 완료([signupCompletePath]) → HOME(Coach Mark). 서버에 약관 동의가 없는 계정은 +/// 「로그인」 버튼으로 들어왔어도 셸로 가지 못하고 [onboardingPath] 로 간다. GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { // 저장소 선택은 앱이 뜰 때 한 번만 한다. 탭을 옮길 때마다 다시 만들면 // Firestore 커서 같은 화면 상태가 매번 초기화된다. @@ -95,11 +101,23 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { refreshListenable: auth, redirect: (context, state) { 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) return at == signupCompletePath ? null : signupCompletePath; - if (at == loginPath || at == signupCompletePath) + if (const [ + loginPath, + signupCompletePath, + introPath, + onboardingPath, + ].contains(at)) { return appTabs.first.path; + } return null; }, routes: [ @@ -107,6 +125,24 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { path: loginPath, 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( path: signupCompletePath, builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup), @@ -230,7 +266,13 @@ class AppShell extends StatelessWidget { ), destinations: [ 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, + ), ], ), ), diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 7e13b60..e2e8187 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -8,21 +8,84 @@ import 'firebase_account_linker.dart'; import 'social_providers.dart'; import '../../core/i18n/app_locale.dart'; -/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. +/// 온보딩 관문 — 로그인한 계정이 가입 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`). +enum OnboardingGate { checking, required, done } + +/// 로그인 상태 보관소. 라우터가 이걸 듣고 소개/로그인/온보딩/셸을 가른다. class AuthRepository extends ChangeNotifier { AuthRepository({ List? providers, AuthSession? session, EmailAuthProvider? qaEmailProvider, bool? releaseMode, + Future Function(String uid)? needsOnboarding, + bool introPending = false, + Future Function()? onIntroDone, }) : providers = providers ?? _defaultProviders(), _qaEmail = qaEmailProvider ?? const EmailPasswordProvider(), _session = session, - _account = session?.currentAccount, + _needsOnboarding = needsOnboarding, + _introPending = introPending, + _onIntroDone = onIntroDone, // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다 // (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다). _releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) { _sessionSubscription = session?.accountChanges.listen(_replaceAccount); + _replaceAccount(session?.currentAccount); + } + + /// 이 계정이 온보딩(약관부터)을 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트). + /// + /// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼 구분이나 + /// 제공자(카카오 커스텀 토큰 포함)와 무관하게, 서버에 약관 동의 기록이 없으면 셸에 들어가지 못한다. + final Future Function(String uid)? _needsOnboarding; + + OnboardingGate _gate = OnboardingGate.done; + OnboardingGate get onboarding => _gate; + + /// 가입 모드에서 제공자 인증 **전에** 동의한 약관(Figma `177:9451` → `179:24230`). + /// 인증 뒤 온보딩이 이 값으로 동의를 기록하고 약관 단계를 건너뛴다. + Set? pendingConsent; + + /// 온보딩 6단계를 마쳤다 — 가입 완료 화면(`182:29344`)을 한 번 띄우고 셸로 간다. + void completeOnboarding() { + _gate = OnboardingGate.done; + pendingConsent = null; + _justSignedUp = true; + notifyListeners(); + } + + Future _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 Function()? _onIntroDone; + + /// 소개를 넘겼다(「건너뛰기」 포함) — FR-CMN-03 「건너뛰기를 눌러도 로그인 화면으로」. + void finishIntro() { + if (!_introPending) return; + _introPending = false; + unawaited(_onIntroDone?.call()); + notifyListeners(); } final bool _releaseMode; @@ -192,7 +255,14 @@ class AuthRepository extends ChangeNotifier { void _replaceAccount(AuthAccount? next) { if (next == null) _justSignedUp = false; if (_sameAccount(_account, next)) return; + final uidChanged = _account?.uid != next?.uid; _account = next; + if (next == null) { + _gate = OnboardingGate.done; + pendingConsent = null; + } else if (uidChanged) { + unawaited(_checkOnboarding(next)); + } notifyListeners(); } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 8eb2a76..d8efba6 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -213,10 +213,12 @@ class _SimpleLoginScreenState extends State { Future _signIn(SocialProvider provider) async { if (widget.signUp) { - final agreed = await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const TermsScreen())); - if (agreed != true || !mounted) return; + final agreed = await Navigator.of(context).push>( + MaterialPageRoute(builder: (_) => const TermsScreen()), + ); + if (agreed == null || !mounted) return; + // 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다). + widget.auth.pendingConsent = agreed; } setState(() => _busy = true); final outcome = await widget.auth.signIn(provider, signUp: widget.signUp); diff --git a/app/lib/features/auth/onboarding_data.dart b/app/lib/features/auth/onboarding_data.dart new file mode 100644 index 0000000..de2c2a0 --- /dev/null +++ b/app/lib/features/auth/onboarding_data.dart @@ -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 agree(Set agreed, String version); + + /// ③ 프로필 — 닉네임(2~20 · 중복 허용) · 국가 · 표시 언어. 지금 값(있으면)을 돌려준다. + Future<({String nickname, String? country})> currentProfile(); + Future saveProfile({ + required String nickname, + required String country, + required String locale, + }); + + /// ③ 추천인 코드(선택) — `fnReferralAttribute`(10/3 7항 「가입 직후 온보딩에 선택 입력」). + Future attributeReferral(String code); + + /// ④ 관심 아티스트 — 팬덤 행성/성운 목록. [query] 가 있으면 이름 앞부분으로 찾는다. + Future> fandoms({String query = ''}); + + /// ⑤ 행성 가입 — 고른 순서대로 `fnFandomJoin`. 대표가 없던 계정은 첫 가입이 대표가 된다(C-15). + Future join(List 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 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 agree(Set 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 {}; + final nickname = data['nickname']; + final country = data['countryCode']; + return ( + nickname: nickname is String ? nickname : '', + country: country is String && country.isNotEmpty ? country : null, + ); + } + + @override + Future 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 attributeReferral(String code) => _backend.call( + 'fnReferralAttribute', + {'referralCode': code, 'channel': 'onboarding'}, + ); + + @override + Future> 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 join(List fandomIds) async { + for (final id in fandomIds) { + await _backend.call('fnFandomJoin', {'fandomId': id}); + } + } +} + +/// 목 모드 · 테스트용. **정책값이 아니다.** +class SampleOnboardingRepository implements OnboardingRepository { + SampleOnboardingRepository(); + + final joined = []; + Set? agreed; + String? referral; + ({String nickname, String country, String locale})? profile; + + @override + Future agree(Set agreed, String version) async => + this.agreed = agreed; + + @override + Future<({String nickname, String? country})> currentProfile() async => + (nickname: '', country: null); + + @override + Future saveProfile({ + required String nickname, + required String country, + required String locale, + }) async => profile = (nickname: nickname, country: country, locale: locale); + + @override + Future attributeReferral(String code) async => referral = code; + + @override + Future> 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 join(List fandomIds) async => joined.addAll(fandomIds); +} diff --git a/app/lib/features/auth/onboarding_screens.dart b/app/lib/features/auth/onboarding_screens.dart new file mode 100644 index 0000000..9d77093 --- /dev/null +++ b/app/lib/features/auth/onboarding_screens.dart @@ -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 createState() => _IntroScreenState(); +} + +class _IntroScreenState extends State { + 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 createState() => _OnboardingFlowState(); +} + +class _OnboardingFlowState extends State { + _Step _step = _Step.terms; + bool _busy = false; + + /// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary). + final _picked = []; + + @override + void initState() { + super.initState(); + // 가입 모드는 제공자 인증 전에 약관을 받았다 — 다시 묻지 않고 기록만 한다. + final pending = widget.auth.pendingConsent; + if (pending != null) _agree(pending); + } + + Future _run(Future 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 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 _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( + 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 _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( + 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( + 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 picked; + final VoidCallback onNext; + final VoidCallback onBack; + + @override + State<_ArtistStep> createState() => _ArtistStepState(); +} + +class _ArtistStepState extends State<_ArtistStep> { + late Future> _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>( + 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 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 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 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 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(' '); diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index 8ae2533..356248a 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -5,19 +5,34 @@ import '../../core/theme/tokens.dart'; /// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의). /// -/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를 -/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다. +/// 필수 4항을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를 +/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 온보딩은 [onAgreed] 로 받는다. +/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다. /// -/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다. +/// 필수 항목은 Figma 의 서비스 · 위치기반 · 만 14세에 개인정보 처리방침을 더했다(10/3 통합회신 +/// 3절 4 「이용약관 · 개인정보처리방침 문구 제공」). 구성은 해석 확인 요청 E-Q1 회신 대기다. +/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와 +/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). class TermsScreen extends StatefulWidget { - const TermsScreen({super.key}); + const TermsScreen({super.key, this.onAgreed, this.onBack}); + + /// 온보딩 안에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다. + final ValueChanged>? onAgreed; + final VoidCallback? onBack; @override State createState() => _TermsScreenState(); } +/// 동의 기록에 남는 약관 버전. 🟡 임시 — 본문이 자리 표시 문구인 동안은 `draft` 다. +const signupTermsVersion = 'draft-1'; + +/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키. +List get signupTermKeys => [for (final t in _Term.values) t.name]; + enum _Term { service(required: true, body: true), + privacy(required: true, body: true), location(required: true, body: true), age(required: true, body: false), marketing(required: false, body: true); @@ -29,6 +44,7 @@ enum _Term { String label(AppLocalizations l10n) => switch (this) { service => l10n.termsService, + privacy => l10n.termsPrivacy, location => l10n.termsLocation, age => l10n.termsAge, marketing => l10n.termsMarketing, @@ -48,6 +64,12 @@ class _TermsScreenState extends State { bool get _ready => _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) => setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t)); @@ -120,7 +142,7 @@ class _TermsScreenState extends State { children: [ Expanded( child: FilledButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: widget.onBack ?? () => Navigator.of(context).pop(), style: FilledButton.styleFrom( backgroundColor: Colors.black, foregroundColor: AppColors.onCosmic, @@ -131,9 +153,7 @@ class _TermsScreenState extends State { const SizedBox(width: 10), Expanded( child: FilledButton( - onPressed: _ready - ? () => Navigator.of(context).pop(true) - : null, + onPressed: _ready ? _done : null, style: FilledButton.styleFrom( backgroundColor: AppColors.cosmicAccent, foregroundColor: AppColors.onCosmicAccent, diff --git a/app/lib/features/home/coach_mark.dart b/app/lib/features/home/coach_mark.dart new file mode 100644 index 0000000..eedc540 --- /dev/null +++ b/app/lib/features/home/coach_mark.dart @@ -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 armCoachMark() async { + try { + await (await SharedPreferences.getInstance()).setBool(_pendingKey, true); + } catch (_) {} +} + +Future 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( + 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; +} diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index 5c2ce3c..a3d6741 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -1,6 +1,7 @@ // formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다. export '../../core/format.dart' show formatPop; +import 'coach_mark.dart'; import '../planet/planet_history_screen.dart'; import '../galaxy/widgets/galaxy_web_view.dart'; import '../galaxy/galaxy_search_screen.dart'; @@ -121,6 +122,12 @@ class _HomeScreenState extends State { _snapshot = widget.repository.load(); if (widget.galaxyRepository case final repository?) { _galaxy = repository.load(); + // 온보딩을 마친 뒤 첫 HOME — ZAP 이 그려진 다음 Coach Mark(1회)를 띄운다. + Future.wait([_snapshot, _galaxy!]).then((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) maybeShowCoachMark(context, _zapKey); + }); + }, onError: (_) {}); _zap = ZapController( repository, onFailure: (message) { diff --git a/app/lib/features/my/my_data.dart b/app/lib/features/my/my_data.dart index f505e3e..bc562e8 100644 --- a/app/lib/features/my/my_data.dart +++ b/app/lib/features/my/my_data.dart @@ -814,12 +814,18 @@ abstract interface class MyRepository { /// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*. Future<({String current, bool latest})> appVersion(); + + /// 서비스 탈퇴 — `fnAccountWithdraw`(Figma `554:24885`). SNS 연동이 남아 있으면 E-AUTH-14. + Future withdraw(); } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** class SampleMyRepository implements MyRepository { SampleMyRepository(); + @override + Future withdraw() async {} + static final _base = DateTime(2026, 9, 1, 12); String _nickname = '아미보이'; diff --git a/app/lib/features/my/my_repository_firestore.dart b/app/lib/features/my/my_repository_firestore.dart index 68185da..438ee50 100644 --- a/app/lib/features/my/my_repository_firestore.dart +++ b/app/lib/features/my/my_repository_firestore.dart @@ -112,7 +112,8 @@ class FirestoreMyRepository extends SampleMyRepository { // **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로 // 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」 // (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다. - final email = _backend.email ?? ''; + // Kakao(커스텀 토큰)는 Auth 이메일이 비어 있어 서버가 사용자 문서에 남긴 동의 이메일을 먼저 본다. + final email = user.strOr('email', _backend.email ?? ''); final photoUrl = user.strOr('photoUrl'); @@ -167,6 +168,9 @@ class FirestoreMyRepository extends SampleMyRepository { }, SetOptions(merge: true)); } + @override + Future withdraw() => _backend.call('fnAccountWithdraw'); + @override Future updateNickname(String nickname) async { final uid = _backend.requireUid; diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart index 935493f..1f0f54b 100644 --- a/app/lib/features/my/settings_screen.dart +++ b/app/lib/features/my/settings_screen.dart @@ -1,4 +1,8 @@ +import 'package:flutter/foundation.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/push/push_messaging.dart'; @@ -230,7 +234,7 @@ class _SettingsScreenState extends State { title: l10n.settingsWithdrawConfirm, body: l10n.settingsWithdrawBody, action: l10n.settingsWithdraw, - withdraw: true, + onConfirm: _withdraw, ), ), ], @@ -339,6 +343,34 @@ class _SettingsScreenState extends State { await LocaleScope.maybeOf(context)?.select(picked); } + /// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 · + /// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다. + /// 서버도 같은 조건을 다시 본다(E-AUTH-14). + Future _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`. Future _notice(String message) => showAlertDialog(context, message); @@ -347,7 +379,6 @@ class _SettingsScreenState extends State { required String title, String? body, required String action, - bool withdraw = false, Future Function()? onConfirm, }) async { final ok = await showConfirmDialog( @@ -356,11 +387,7 @@ class _SettingsScreenState extends State { body: body, confirmLabel: action, ); - if (ok == true && onConfirm != null) { - await onConfirm(); - } else if (ok == true && withdraw && mounted) { - _toast(context.l10n.settingsWithdrawLater); - } + if (ok == true && onConfirm != null) await onConfirm(); return ok; } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index e2b4410..539412c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -830,5 +830,42 @@ "questModerationRejected": "Rejected in review: {reason}. Your deposit and fee were returned.", "questSubmittedForReview": "Submitted. It will be published after review.", "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." } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index cb2e291..dd2a140 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -830,5 +830,42 @@ "questModerationRejected": "Rechazada en revisión: {reason}. Te devolvimos el depósito y la comisión.", "questSubmittedForReview": "Enviada. Se publicará tras la revisió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." } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 93ae1db..b156ddd 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -830,5 +830,42 @@ "questModerationRejected": "審査で差し戻されました:{reason}。預けたPOPと手数料は返還済みです。", "questSubmittedForReview": "公開を申請しました。審査後に公開されます。", "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": "連携中のアカウントを解除してから退会してください。" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 016946f..cb54c99 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1761,8 +1761,58 @@ "questCancelPublished": "공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.", "questUnderReview": "운영 검수 중이에요. 승인되면 공개됩니다.", "questModerationRejected": "운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.", - "@questModerationRejected": {"placeholders": {"reason": {"type": "String"}}}, + "@questModerationRejected": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "questSubmittedForReview": "공개 요청했어요. 운영 검수 후 공개됩니다.", "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": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요." } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 75d6184..cb035a6 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -5101,6 +5101,228 @@ abstract class AppLocalizations { /// In ko, this message translates to: /// **'목표 걸음 수'** 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 diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index afff113..65a29bf 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -2907,4 +2907,126 @@ class AppLocalizationsEn extends AppLocalizations { @override 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.'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 44ab10c..c42c4a0 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -2922,4 +2922,128 @@ class AppLocalizationsEs extends AppLocalizations { @override 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.'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 3b1448f..62d30e2 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -2811,4 +2811,117 @@ class AppLocalizationsJa extends AppLocalizations { @override 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 => '連携中のアカウントを解除してから退会してください。'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 3e29dfb..fd077ba 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -2814,4 +2814,118 @@ class AppLocalizationsKo extends AppLocalizations { @override 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 => '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 7cb3e47..6e8dfbf 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -2807,4 +2807,117 @@ class AppLocalizationsZh extends AppLocalizations { @override 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 => '请先解除关联账号后再注销账号。'; } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 5680e94..f5d4d8c 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -830,5 +830,42 @@ "questModerationRejected": "审核未通过:{reason}。已退还存入的POP和手续费。", "questSubmittedForReview": "已提交公开申请,审核后公开。", "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": "请先解除关联账号后再注销账号。" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 3b53eec..e63f2fb 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -394,6 +394,12 @@ class RemoteAppLocalizations extends AppLocalizations { @override 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 String get commentDeleteConfirm => _remote['commentDeleteConfirm'] ?? _base.commentDeleteConfirm; @@ -664,6 +670,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get eclipseSuccessTitle => _remote['eclipseSuccessTitle'] ?? _base.eclipseSuccessTitle; + @override + String get eclipseTapToJoin => _remote['eclipseTapToJoin'] ?? _base.eclipseTapToJoin; + @override String get eclipseTimeLeft => _remote['eclipseTimeLeft'] ?? _base.eclipseTimeLeft; @@ -1321,6 +1330,102 @@ class RemoteAppLocalizations extends AppLocalizations { @override 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 String get openSettings => _remote['openSettings'] ?? _base.openSettings; @@ -2245,6 +2350,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater; + @override + String get settingsWithdrawLinked => _remote['settingsWithdrawLinked'] ?? _base.settingsWithdrawLinked; + @override String shakeCombo(String n) => _fill('shakeCombo', {'n': n}) ?? _base.shakeCombo(n); @@ -2377,6 +2485,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get termsMarketing => _remote['termsMarketing'] ?? _base.termsMarketing; + @override + String get termsPrivacy => _remote['termsPrivacy'] ?? _base.termsPrivacy; + @override String get termsService => _remote['termsService'] ?? _base.termsService; diff --git a/app/lib/main.dart b/app/lib/main.dart index 4307e62..e4555f3 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,14 +1,35 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'core/firebase/firebase_boot.dart'; import 'features/auth/auth_repository.dart'; import 'features/auth/firebase_auth_session.dart'; +import 'features/auth/onboarding_data.dart'; + +/// 소개 3장(온보딩 ①)을 이 기기에서 넘겼는가. +const _introSeenKey = 'onboarding.introSeen'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고. await FirebaseBoot.init(); - final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null; - runApp(FancakeApp(auth: AuthRepository(session: session))); + final live = FirebaseBoot.isLive; + 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), + ), + ), + ); } diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index 3d800da..d38bd1b 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -180,10 +180,7 @@ void main() { testWidgets('행성/성운 칸은 가입 목록을 팬덤 stage 그림으로 그린다', (tester) async { await pumpWide( tester, - MyScreen( - repository: SampleMyRepository(), - galaxy: _OneNebula(), - ), + MyScreen(repository: SampleMyRepository(), galaxy: _OneNebula()), ); await tester.pump(); final images = tester @@ -191,7 +188,10 @@ void main() { .map((i) => (i.image as AssetImage).assetName) .toList(); 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); }); @@ -233,8 +233,12 @@ void main() { return null; }, ); - addTearDown(() => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null)); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); expect(await shareProfile(profile, '문구 복사'), isTrue); expect(clip, profileShareText(profile)); }); @@ -471,6 +475,41 @@ void main() { expect(find.text('취소'), findsOneWidget); }); + Future 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 { await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await tester.pump(); @@ -1112,9 +1151,22 @@ class _OneNebula extends SampleGalaxyRepository { myPop: 0, memberships: [ PlanetNode( - id: 'n1', name: '새 성운', rank: 0, fpi: 0, members: 1, online: 0, - isMine: true, stage: 0, + id: 'n1', + name: '새 성운', + rank: 0, + fpi: 0, + members: 1, + online: 0, + isMine: true, + stage: 0, ), ], ); } + +class _WithdrawRepo extends SampleMyRepository { + bool withdrawn = false; + + @override + Future withdraw() async => withdrawn = true; +} diff --git a/app/test/onboarding_test.dart b/app/test/onboarding_test.dart new file mode 100644 index 0000000..7f36323 --- /dev/null +++ b/app/test/onboarding_test.dart @@ -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(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 get missingConfig => const []; + @override + Future signIn() async => const AuthSucceeded( + AuthAccount(uid: 'onb-user', provider: SocialProvider.google), + ); + @override + Future signOut() async {} +}