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

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:25 +09:00
parent e7010010bb
commit 55424bd4a2
36 changed files with 2884 additions and 77 deletions
+72 -2
View File
@@ -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<SocialAuthProvider>? providers,
AuthSession? session,
EmailAuthProvider? qaEmailProvider,
bool? releaseMode,
Future<bool> Function(String uid)? needsOnboarding,
bool introPending = false,
Future<void> 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<bool> Function(String uid)? _needsOnboarding;
OnboardingGate _gate = OnboardingGate.done;
OnboardingGate get onboarding => _gate;
/// 가입 모드에서 제공자 인증 **전에** 동의한 약관(Figma `177:9451` → `179:24230`).
/// 인증 뒤 온보딩이 이 값으로 동의를 기록하고 약관 단계를 건너뛴다.
Set<String>? pendingConsent;
/// 온보딩 6단계를 마쳤다 — 가입 완료 화면(`182:29344`)을 한 번 띄우고 셸로 간다.
void completeOnboarding() {
_gate = OnboardingGate.done;
pendingConsent = null;
_justSignedUp = true;
notifyListeners();
}
Future<void> _checkOnboarding(AuthAccount account) async {
final check = _needsOnboarding;
if (check == null || account.isMock) {
_gate = OnboardingGate.done;
return;
}
_gate = OnboardingGate.checking;
bool required;
try {
required = await check(account.uid);
} catch (e) {
// 판정하지 못하면 약관부터 다시 받는다 — 동의 없는 계정이 셸로 새는 쪽이 더 나쁘다.
debugPrint('[AuthRepository] onboarding check failed: ${e.runtimeType}');
required = true;
}
if (_account?.uid != account.uid) return;
_gate = required ? OnboardingGate.required : OnboardingGate.done;
notifyListeners();
}
/// 앱 소개 3장(온보딩 ①)을 아직 보지 않았다 — 첫 실행에서 로그인 화면 앞에 한 번.
bool get introPending => _introPending;
bool _introPending;
final Future<void> Function()? _onIntroDone;
/// 소개를 넘겼다(「건너뛰기」 포함) — FR-CMN-03 「건너뛰기를 눌러도 로그인 화면으로」.
void finishIntro() {
if (!_introPending) return;
_introPending = false;
unawaited(_onIntroDone?.call());
notifyListeners();
}
final bool _releaseMode;
@@ -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();
}