가입 시 약관 동의 단계 추가 — 필수·선택 항목 구분 · 개인정보처리방침 고지 · 동의 기록 저장

This commit is contained in:
Sn-Kinos
2026-10-11 23:43:25 +09:00
parent 9119b3beaa
commit c40bde2cd0
9 changed files with 564 additions and 51 deletions
+16 -6
View File
@@ -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: [
+72 -1
View File
@@ -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();
}
+9 -5
View File
@@ -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;
}
}
+192 -35
View File
@@ -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(
children: [
Expanded(
child: FilledButton(
onPressed: () => Navigator.of(context).pop(false),
style: FilledButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: AppColors.onCosmic,
// 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다.
Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
child: Row(
children: [
const Expanded(
child: Text(
'개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.',
style: TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.36,
color: AppColors.onCosmicMuted,
),
),
child: const Text('이전'),
),
),
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
View File
@@ -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;
}