가입 시 약관 동의 단계 추가 — 필수·선택 항목 구분 · 개인정보처리방침 고지 · 동의 기록 저장
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as functionsV1 from 'firebase-functions/v1';
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
@@ -85,6 +85,57 @@ export const fnConsentRecord = onCall(async (req) => {
|
||||
return { consentId };
|
||||
});
|
||||
|
||||
/**
|
||||
* 가입 약관 필수 항목 — 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인.
|
||||
*
|
||||
* 위치기반 서비스 약관은 가입 때 받지 않는다(위치기능을 처음 쓰기 직전에 따로 받는다).
|
||||
* 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF.
|
||||
*/
|
||||
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
|
||||
|
||||
/**
|
||||
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다.
|
||||
*
|
||||
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
|
||||
* 찍는다. 앱은 이 값이 없으면 로그인 직후 약관 화면으로 보낸다 — 가입 · 로그인 버튼이나 제공자와
|
||||
* 무관하게 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
|
||||
* 마케팅 동의는 알림 설정의 마케팅 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
|
||||
*/
|
||||
export const fnSignupConsent = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const version = str(req.data, 'version');
|
||||
const raw = (req.data as Record<string, unknown>)?.agreed;
|
||||
const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []);
|
||||
if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) {
|
||||
throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.');
|
||||
}
|
||||
const marketing = agreed.has('marketing');
|
||||
const db = getFirestore();
|
||||
const batch = db.batch();
|
||||
const meta = {
|
||||
docVersion: version,
|
||||
ip: req.rawRequest?.ip ?? null,
|
||||
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) {
|
||||
const granted = docType === 'marketing' ? marketing : true;
|
||||
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
|
||||
{ docType, granted, ...meta }, { merge: true });
|
||||
}
|
||||
batch.set(db.doc(`users/${uid}`), {
|
||||
termsAgreedAt: FieldValue.serverTimestamp(),
|
||||
termsVersion: version,
|
||||
notificationPrefs: { marketing },
|
||||
consentState: {
|
||||
marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() },
|
||||
},
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
await batch.commit();
|
||||
return { termsVersion: version, marketing };
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import './core/bootstrap.js';
|
||||
|
||||
export {
|
||||
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
|
||||
fnAuthOnCreate, fnConsentRecord, fnSignupConsent, fnAdminSetRole,
|
||||
} from './account/account.js';
|
||||
export { fnAuthKakao } from './account/kakao.js';
|
||||
export { fnAuthLine } from './account/line.js';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { fnSignupConsent } from '../src/account/account.js';
|
||||
|
||||
/** 가입 약관 동의 — 필수 3항 검사 · 항목별 동의 행 · 동의 시각 기록. */
|
||||
|
||||
const U = 'test-consent-user';
|
||||
const db = () => getFirestore();
|
||||
|
||||
type CallReq = Parameters<typeof fnSignupConsent.run>[0];
|
||||
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
|
||||
const codeOf = async (p: Promise<unknown>) => {
|
||||
try { await p; return 'ok'; } catch (e) { return (e as { code?: string }).code; }
|
||||
};
|
||||
|
||||
const wipe = () => db().recursiveDelete(db().doc(`users/${U}`));
|
||||
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||
beforeEach(wipe);
|
||||
|
||||
describe('fnSignupConsent', () => {
|
||||
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부하고 기록하지 않는다', async () => {
|
||||
expect(await codeOf(fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }))))
|
||||
.toBe('invalid-argument');
|
||||
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('필수만 동의 — 항목별 행 · 마케팅 기본 OFF · termsAgreedAt', async () => {
|
||||
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age'] }));
|
||||
const user = (await db().doc(`users/${U}`).get()).data()!;
|
||||
expect(user.termsAgreedAt).toBeDefined();
|
||||
expect(user.termsVersion).toBe('v1');
|
||||
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', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
|
||||
});
|
||||
|
||||
it('마케팅 선택 동의는 알림 마케팅 구분과 같은 값이 된다', async () => {
|
||||
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age', 'marketing'] }));
|
||||
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
|
||||
expect((await db().doc(`users/${U}/consents/marketing_v1_grant`).get()).get('granted')).toBe(true);
|
||||
});
|
||||
|
||||
it('로그인하지 않은 호출은 거부', async () => {
|
||||
expect(await codeOf(fnSignupConsent.run({ data: { version: 'v1', agreed: [] } } as CallReq)))
|
||||
.not.toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import '../theme/tokens.dart';
|
||||
import '../ui/app_icons.dart';
|
||||
import '../../features/auth/auth_repository.dart';
|
||||
import '../../features/auth/login_screen.dart';
|
||||
import '../../features/auth/signup_screens.dart';
|
||||
import '../../features/galaxy/galaxy_screen.dart';
|
||||
import '../../features/home/home_screen.dart';
|
||||
import '../../features/inside/inside_screen.dart';
|
||||
@@ -77,19 +78,24 @@ List<AppTab> visibleTabs(FeatureFlags flags) => [
|
||||
|
||||
const String loginPath = '/login';
|
||||
|
||||
/// 5탭 셸 라우트 + 온보딩(로그인) 라우트.
|
||||
/// 가입 약관 — 동의 기록(`termsAgreedAt`)이 없는 계정은 셸 대신 여기로 온다.
|
||||
const String termsPath = '/terms';
|
||||
|
||||
/// 5탭 셸 라우트 + 로그인 · 가입 약관 라우트.
|
||||
///
|
||||
/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST ·
|
||||
/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다.
|
||||
/// 로그인 → (동의 기록이 없으면) 약관([termsPath]) → 셸. 가입 · 로그인 버튼이나 제공자와
|
||||
/// 무관하게 약관 동의 없이 셸에 들어가지 못한다.
|
||||
GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
final featureFlags = flags ?? FeatureFlags();
|
||||
return GoRouter(
|
||||
initialLocation: appTabs.first.path,
|
||||
refreshListenable: auth,
|
||||
redirect: (context, state) {
|
||||
final atLogin = state.matchedLocation == loginPath;
|
||||
if (!auth.isSignedIn) return atLogin ? null : loginPath;
|
||||
if (atLogin) return appTabs.first.path;
|
||||
final at = state.matchedLocation;
|
||||
if (!auth.isSignedIn) return at == loginPath ? null : loginPath;
|
||||
if (auth.terms != TermsGate.done)
|
||||
return at == termsPath ? null : termsPath;
|
||||
if (at == loginPath || at == termsPath) return appTabs.first.path;
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -97,6 +103,10 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
path: loginPath,
|
||||
builder: (_, _) => LoginScreen(auth: auth),
|
||||
),
|
||||
GoRoute(
|
||||
path: termsPath,
|
||||
builder: (_, _) => SignupTermsGate(auth: auth),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags),
|
||||
branches: [
|
||||
|
||||
@@ -6,21 +6,85 @@ import '../../core/firebase/firebase_boot.dart';
|
||||
import 'auth.dart';
|
||||
import 'social_providers.dart';
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
||||
/// 가입 약관 관문 — 로그인한 계정이 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`).
|
||||
enum TermsGate { checking, required, done }
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/약관/셸을 가른다.
|
||||
class AuthRepository extends ChangeNotifier {
|
||||
AuthRepository({
|
||||
List<SocialAuthProvider>? providers,
|
||||
AuthSession? session,
|
||||
EmailAuthProvider? qaEmailProvider,
|
||||
bool? releaseMode,
|
||||
Future<bool> Function(String uid)? needsTerms,
|
||||
Future<void> Function(Set<String> agreed)? recordTerms,
|
||||
}) : providers = providers ?? _defaultProviders(),
|
||||
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
|
||||
_session = session,
|
||||
_needsTerms = needsTerms,
|
||||
_recordTerms = recordTerms,
|
||||
_account = session?.currentAccount,
|
||||
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
||||
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
||||
_releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) {
|
||||
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
|
||||
final initial = _account;
|
||||
if (initial != null) unawaited(_checkTerms(initial));
|
||||
}
|
||||
|
||||
/// 이 계정이 약관 동의를 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트).
|
||||
///
|
||||
/// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼이나
|
||||
/// 제공자(카카오 · LINE 커스텀 토큰 포함)와 무관하게, 서버에 동의 기록이 없으면 셸에 들어가지 못한다.
|
||||
final Future<bool> Function(String uid)? _needsTerms;
|
||||
|
||||
/// 동의 기록(`fnSignupConsent`). 필수 누락은 서버가 거부한다.
|
||||
final Future<void> Function(Set<String> agreed)? _recordTerms;
|
||||
|
||||
TermsGate _gate = TermsGate.done;
|
||||
TermsGate get terms => _gate;
|
||||
|
||||
/// 가입 모드에서 제공자 인증 **전에** 동의한 약관. 인증 뒤 동의가 필요한 계정이면
|
||||
/// 이 값으로 기록하고 약관 화면을 다시 띄우지 않는다.
|
||||
Set<String>? pendingConsent;
|
||||
|
||||
/// 약관 동의를 서버에 기록하고 셸로 보낸다. 실패하면 예외를 그대로 던진다(화면이 안내).
|
||||
Future<void> agreeTerms(Set<String> agreed) async {
|
||||
await _recordTerms?.call(agreed);
|
||||
_gate = TermsGate.done;
|
||||
pendingConsent = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _checkTerms(AuthAccount account) async {
|
||||
final check = _needsTerms;
|
||||
if (check == null || account.isMock) {
|
||||
_gate = TermsGate.done;
|
||||
return;
|
||||
}
|
||||
_gate = TermsGate.checking;
|
||||
bool required;
|
||||
try {
|
||||
required = await check(account.uid);
|
||||
} catch (e) {
|
||||
// 판정하지 못하면 약관부터 다시 받는다 — 동의 없는 계정이 셸로 새는 쪽이 더 나쁘다.
|
||||
debugPrint('[AuthRepository] terms check failed: ${e.runtimeType}');
|
||||
required = true;
|
||||
}
|
||||
if (_account?.uid != account.uid) return;
|
||||
final pending = pendingConsent;
|
||||
pendingConsent = null;
|
||||
if (required && pending != null) {
|
||||
try {
|
||||
await agreeTerms(pending);
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('[AuthRepository] pending consent failed: ${e.runtimeType}');
|
||||
}
|
||||
if (_account?.uid != account.uid) return;
|
||||
}
|
||||
_gate = required ? TermsGate.required : TermsGate.done;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final bool _releaseMode;
|
||||
@@ -172,7 +236,14 @@ class AuthRepository extends ChangeNotifier {
|
||||
|
||||
void _replaceAccount(AuthAccount? next) {
|
||||
if (_sameAccount(_account, next)) return;
|
||||
final uidChanged = _account?.uid != next?.uid;
|
||||
_account = next;
|
||||
if (next == null) {
|
||||
_gate = TermsGate.done;
|
||||
pendingConsent = null;
|
||||
} else if (uidChanged) {
|
||||
unawaited(_checkTerms(next));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -210,13 +210,16 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||
|
||||
Future<void> _signIn(SocialProvider provider) async {
|
||||
if (widget.signUp) {
|
||||
final agreed = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => const TermsScreen()));
|
||||
if (agreed != true || !mounted) return;
|
||||
final agreed = await Navigator.of(context).push<Set<String>>(
|
||||
MaterialPageRoute(builder: (_) => const TermsScreen()),
|
||||
);
|
||||
if (agreed == null || !mounted) return;
|
||||
// 인증 뒤 동의가 필요한 계정이면 이 동의를 서버에 기록한다(약관을 다시 묻지 않는다).
|
||||
widget.auth.pendingConsent = agreed;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider);
|
||||
if (outcome is! AuthSucceeded) widget.auth.pendingConsent = null;
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
|
||||
@@ -266,7 +269,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||
for (final kind in _figmaOrder)
|
||||
...widget.auth.providers.where((p) => p.provider == kind),
|
||||
];
|
||||
return Scaffold(
|
||||
final scaffold = Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
@@ -345,6 +348,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
return scaffold;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'auth_repository.dart';
|
||||
|
||||
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
|
||||
///
|
||||
/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를
|
||||
/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다.
|
||||
/// 필수 항목을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
|
||||
/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 로그인 직후 약관 단계는 [onAgreed] 로 받는다.
|
||||
/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다.
|
||||
///
|
||||
/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다.
|
||||
/// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상.
|
||||
/// - 선택: 마케팅 수신(기본 OFF).
|
||||
/// - 위치기반 서비스 약관은 여기서 받지 않는다 — 위치기능 최초 사용 직전에 따로 받는다.
|
||||
/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 목록 끝에 안내와 보기 링크만 둔다.
|
||||
///
|
||||
/// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면
|
||||
/// [termsPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
|
||||
class TermsScreen extends StatefulWidget {
|
||||
const TermsScreen({super.key});
|
||||
const TermsScreen({super.key, this.onAgreed, this.onBack, this.busy = false});
|
||||
|
||||
/// 로그인 직후 약관 단계에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다.
|
||||
final ValueChanged<Set<String>>? onAgreed;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// 동의를 기록하는 중 — 버튼을 막고 진행 표시를 띄운다.
|
||||
final bool busy;
|
||||
|
||||
@override
|
||||
State<TermsScreen> createState() => _TermsScreenState();
|
||||
}
|
||||
|
||||
/// 동의 기록에 남는 약관 버전. 본문이 자리 표시 문구인 동안은 `draft` 다.
|
||||
const signupTermsVersion = 'draft-1';
|
||||
|
||||
/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키.
|
||||
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
|
||||
|
||||
enum _Term {
|
||||
service('서비스 이용약관 (필수)', required: true, body: true),
|
||||
location('위치기반 서비스 이용약관 (필수)', required: true, body: true),
|
||||
privacy('개인정보 수집·이용 동의 (필수)', required: true, body: true),
|
||||
age('만 14세 이상입니다. (필수)', required: true, body: false),
|
||||
marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true);
|
||||
|
||||
@@ -28,7 +50,8 @@ enum _Term {
|
||||
final bool body;
|
||||
}
|
||||
|
||||
const _body =
|
||||
/// 약관 본문 자리 표시 문구.
|
||||
const termsPlaceholderBody =
|
||||
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
|
||||
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
|
||||
|
||||
@@ -41,13 +64,88 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
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 _back() => (widget.onBack ?? () => Navigator.of(context).pop())();
|
||||
|
||||
void _toggle(_Term t) =>
|
||||
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t));
|
||||
|
||||
void _showPrivacyPolicy() => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('개인정보처리방침'),
|
||||
content: const SingleChildScrollView(child: Text(termsPlaceholderBody)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buttons() {
|
||||
final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
);
|
||||
final busy = widget.busy;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: busy ? null : _back,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: shape,
|
||||
side: const BorderSide(color: _stroke),
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _ready && !busy ? _done : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: shape,
|
||||
side: const BorderSide(color: _stroke),
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
disabledBackgroundColor: const Color(0xFF333333),
|
||||
disabledForegroundColor: const Color(0xFF777777),
|
||||
),
|
||||
child: const Text('완료'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('이용 약관')),
|
||||
final scaffold = Scaffold(
|
||||
// Figma `179:24230` 머리줄 좌측 「<」 — 「이전」과 같은 동작.
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: BackButton(onPressed: widget.busy ? null : _back),
|
||||
title: const Text('이용 약관'),
|
||||
centerTitle: true,
|
||||
),
|
||||
// 「이전 · 완료」는 화면 하단 고정(56 높이 · 간격 10). 시스템 내비게이션 바 위로 올린다.
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: _buttons(),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
@@ -85,7 +183,7 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
height: 80,
|
||||
child: ClipRect(
|
||||
child: Text(
|
||||
_body,
|
||||
termsPlaceholderBody,
|
||||
overflow: TextOverflow.clip,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
@@ -108,42 +206,101 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpace.s6),
|
||||
Row(
|
||||
// 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
),
|
||||
child: const Text('이전'),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontFamilyFallback: [AppFonts.body],
|
||||
fontSize: 12,
|
||||
height: 1.36,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _ready
|
||||
? () => Navigator.of(context).pop(true)
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
disabledBackgroundColor: const Color(0xFF333333),
|
||||
disabledForegroundColor: const Color(0xFF777777),
|
||||
),
|
||||
child: const Text('완료'),
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('privacy-policy-link'),
|
||||
onPressed: _showPrivacyPolicy,
|
||||
child: const Text('보기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!widget.busy) return scaffold;
|
||||
return Stack(children: [scaffold, const BlockingProgress()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 진행 중 화면 전체를 막는 반투명 막 + 가운데 진행 표시.
|
||||
class BlockingProgress extends StatelessWidget {
|
||||
const BlockingProgress({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Stack(
|
||||
children: [
|
||||
ModalBarrier(dismissible: false, color: Color(0x8A000000)),
|
||||
Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 로그인 직후 약관 단계 — 동의 기록이 없는 계정은 HOME 대신 여기로 온다.
|
||||
///
|
||||
/// 판정 중(또는 이미 동의한 계정이 셸로 넘어가는 동안)에는 진행 표시만 그린다.
|
||||
/// 「이전」은 로그아웃하고 로그인 화면으로 돌아간다 — 동의 없이 셸에 들어가는 길이 없다.
|
||||
class SignupTermsGate extends StatefulWidget {
|
||||
const SignupTermsGate({super.key, required this.auth});
|
||||
|
||||
final AuthRepository auth;
|
||||
|
||||
@override
|
||||
State<SignupTermsGate> createState() => _SignupTermsGateState();
|
||||
}
|
||||
|
||||
class _SignupTermsGateState extends State<SignupTermsGate> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _agree(Set<String> agreed) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.auth.agreeTerms(agreed);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.maybeOf(context)
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is BackendException ? e.message : '요청을 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: widget.auth,
|
||||
builder: (context, _) => widget.auth.terms == TermsGate.required
|
||||
? TermsScreen(
|
||||
busy: _busy,
|
||||
onAgreed: _agree,
|
||||
onBack: widget.auth.signOut,
|
||||
)
|
||||
: const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
);
|
||||
}
|
||||
|
||||
/// 약관 칸 — #545454 획 · 모서리 8 · 좌우 16.
|
||||
class _Box extends StatelessWidget {
|
||||
const _Box({required this.child});
|
||||
|
||||
+25
-2
@@ -1,14 +1,37 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'core/data/backend.dart';
|
||||
import 'core/firebase/firebase_boot.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
import 'features/auth/firebase_auth_session.dart';
|
||||
import 'features/auth/signup_screens.dart';
|
||||
|
||||
Future<void> 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;
|
||||
runApp(
|
||||
FancakeApp(
|
||||
auth: AuthRepository(
|
||||
session: live ? FirebaseAuthSession() : null,
|
||||
needsTerms: live ? _needsTerms : null,
|
||||
recordTerms: live
|
||||
? (agreed) => Backend().call('fnSignupConsent', {
|
||||
'agreed': agreed.toList(),
|
||||
'version': signupTermsVersion,
|
||||
})
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 동의 기록(`termsAgreedAt`)이 없으면 약관을 받아야 한다. 갓 생성된 계정은 트리거가 아직
|
||||
/// 사용자 문서를 만들지 않았을 수 있다 — 그래도 동의가 필요하다.
|
||||
Future<bool> _needsTerms(String uid) async {
|
||||
final snap = await FirebaseFirestore.instance.doc('users/$uid').get();
|
||||
return snap.data()?['termsAgreedAt'] == null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fancake/app.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:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 가입 약관 — 동의 기록이 없는 계정은 HOME 대신 약관으로 가고, 동의를 기록한 뒤에만 HOME 에 들어간다.
|
||||
void main() {
|
||||
setUp(() {
|
||||
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '가입 약관 위젯 테스트');
|
||||
});
|
||||
|
||||
const newUser = AuthAccount(
|
||||
uid: 'new-user',
|
||||
provider: AuthProviderKind.kakao,
|
||||
);
|
||||
|
||||
testWidgets('동의 없는 새 계정은 약관 → 필수 동의 → HOME', (tester) async {
|
||||
final signIn = Completer<AuthOutcome>();
|
||||
final provider = _Provider(AuthProviderKind.kakao, () => signIn.future);
|
||||
Set<String>? recorded;
|
||||
final auth = AuthRepository(
|
||||
providers: [provider],
|
||||
releaseMode: false,
|
||||
needsTerms: (_) async => recorded == null,
|
||||
recordTerms: (agreed) async => recorded = agreed,
|
||||
);
|
||||
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text(providerButtonLabel(AuthProviderKind.kakao)));
|
||||
await tester.pump();
|
||||
signIn.complete(const AuthSucceeded(newUser));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.text('이용 약관'), findsOneWidget);
|
||||
expect(find.text('위치기반 서비스 이용약관 (필수)'), findsNothing);
|
||||
final done = find.widgetWithText(FilledButton, '완료');
|
||||
expect(tester.widget<FilledButton>(done).onPressed, isNull);
|
||||
for (final label in [
|
||||
'서비스 이용약관 (필수)',
|
||||
'개인정보 수집·이용 동의 (필수)',
|
||||
'만 14세 이상입니다. (필수)',
|
||||
]) {
|
||||
await tester.scrollUntilVisible(find.text(label), 100);
|
||||
await tester.tap(find.text(label));
|
||||
await tester.pump();
|
||||
}
|
||||
// 개인정보처리방침은 체크 칸 없는 고지 · 보기 링크.
|
||||
final policy = find.byKey(const ValueKey('privacy-policy-link'));
|
||||
await tester.scrollUntilVisible(policy, 200);
|
||||
await tester.tap(policy);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('개인정보처리방침'), findsOneWidget);
|
||||
await tester.tap(find.text('확인'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.widget<FilledButton>(done).onPressed, isNotNull);
|
||||
await tester.tap(done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(recorded, {'service', 'privacy', 'age'});
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('이미 동의한 계정은 약관 없이 HOME', (tester) async {
|
||||
final auth = AuthRepository(
|
||||
providers: [
|
||||
_Provider(
|
||||
AuthProviderKind.google,
|
||||
() async => const AuthSucceeded(newUser),
|
||||
),
|
||||
],
|
||||
releaseMode: false,
|
||||
needsTerms: (_) async => false,
|
||||
recordTerms: (_) async => fail('기록하지 않는다'),
|
||||
);
|
||||
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(providerButtonLabel(AuthProviderKind.google)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('가입 모드 — 인증 전에 받은 동의를 기록하고 약관을 다시 묻지 않는다', (tester) async {
|
||||
Set<String>? recorded;
|
||||
final auth = AuthRepository(
|
||||
providers: [
|
||||
_Provider(
|
||||
AuthProviderKind.line,
|
||||
() async => const AuthSucceeded(newUser),
|
||||
),
|
||||
],
|
||||
releaseMode: false,
|
||||
needsTerms: (_) async => recorded == null,
|
||||
recordTerms: (agreed) async => recorded = agreed,
|
||||
);
|
||||
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('회원가입'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.text(providerButtonLabel(AuthProviderKind.line, signUp: true)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('전체 동의'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, '완료'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(recorded, {'service', 'privacy', 'age', 'marketing'});
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
final class _Provider implements SocialAuthProvider {
|
||||
_Provider(this.provider, this._signIn);
|
||||
|
||||
@override
|
||||
final AuthProviderKind provider;
|
||||
final Future<AuthOutcome> Function() _signIn;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => const [];
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() {
|
||||
calls += 1;
|
||||
return _signIn();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
}
|
||||
Reference in New Issue
Block a user