import 'dart:async'; import 'package:flutter/foundation.dart'; 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? providers, AuthSession? session, EmailAuthProvider? qaEmailProvider, bool? releaseMode, Future Function(String uid)? needsTerms, Future Function(Set 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 Function(String uid)? _needsTerms; /// 동의 기록(`fnSignupConsent`). 필수 누락은 서버가 거부한다. final Future Function(Set agreed)? _recordTerms; TermsGate _gate = TermsGate.done; TermsGate get terms => _gate; /// 가입 모드에서 제공자 인증 **전에** 동의한 약관. 인증 뒤 동의가 필요한 계정이면 /// 이 값으로 기록하고 약관 화면을 다시 띄우지 않는다. Set? pendingConsent; /// 약관 동의를 서버에 기록하고 셸로 보낸다. 실패하면 예외를 그대로 던진다(화면이 안내). Future agreeTerms(Set agreed) async { await _recordTerms?.call(agreed); _gate = TermsGate.done; pendingConsent = null; notifyListeners(); } Future _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; final AuthSession? _session; final EmailAuthProvider _qaEmail; StreamSubscription? _sessionSubscription; /// 로그인 화면에 그릴 제공자 목록. **여기 없는 수단은 사용자가 닿을 수 없다.** final List providers; AuthAccount? _account; AuthAccount? get account => _account; bool get isSignedIn => _account != null; /// **별첨 B v1.4 확정 4종.** 순서는 회신 문구 순서다(화면 버튼은 Figma 순서로 다시 줄 세운다). /// /// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 /// > X 로그인은 제외합니다. /// /// **이메일은 여기 없다.** 목록에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면 /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. /// QA 경로는 [qaEmailProvider] 하나뿐이다. /// /// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다. static List _defaultProviders() => [ const GoogleSignInProvider(), // Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 — // 서비스 ID · 키는 Firebase 콘솔 Apple 제공자 설정에 두고 앱에는 값이 없다. if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) const OidcSignInProvider( AuthProviderKind.apple, providerId: 'apple.com', scopes: ['email', 'name'], ) else const AppleSignInProvider(), // 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱 // 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다. KakaoSignInProvider(), // LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다). LineSignInProvider(), ]; /// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**. /// /// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 그래도 발주사가 /// 로그인 이후 화면을 확인하려면 실 계정 하나로 들어갈 수단이 필요해 코드를 /// 남겼고, 릴리스 빌드에서는 null 이라 부를 수 있는 경로 자체가 없다. /// /// [isMockSessionAvailable] 과 같은 이유로 빌드 모드로 막는다 — 릴리스 /// 산출물에 내부 우회 입구가 새어 나가면 안 된다. EmailAuthProvider? get qaEmailProvider => _releaseMode ? null : _qaEmail; /// QA 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다. Future signInWithEmail({ required String email, required String password, bool signUp = false, }) async { final impl = qaEmailProvider; if (impl == null) { return const AuthFailed('E-AUTH-03', '이메일 로그인을 쓸 수 없습니다.'); } late final AuthOutcome outcome; try { outcome = signUp ? await impl.signUpWithEmail(email, password) : await impl.signInWithEmail(email, password); } on Object catch (error) { debugPrint('[AuthRepository] email sign-in failed: ${error.runtimeType}'); return const AuthFailed('E-AUTH-01', '이메일 또는 비밀번호를 확인해 주세요.'); } if (outcome is AuthSucceeded) _replaceAccount(outcome.account); return outcome; } Future signIn(SocialProvider provider) async { final impl = providers.firstWhere( (p) => p.provider == provider, orElse: () => throw ArgumentError.value( provider, 'provider', '노출 목록에 없는 로그인 수단이다 (별첨 B v1.4 확정 4종)', ), ); late final AuthOutcome outcome; try { outcome = await impl.signIn(); } on Object catch (error) { // SDK가 문서화하지 않은 플랫폼 예외를 던져도 로그인 화면이 busy 상태에 // 고착되지 않게 사용자용 결과로 닫는다. 원문 예외·토큰은 화면에 노출하지 않는다. debugPrint( '[AuthRepository] ${provider.name} sign-in failed: ${error.runtimeType}', ); outcome = const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } if (outcome is AuthSucceeded) { _replaceAccount(outcome.account); } return outcome; } /// Firebase 미설정 상태에서 탭 이동을 시연하기 위한 임시 세션. /// /// 별첨2 2주차 발주사 확인 항목에 「앱 설치·로그인·탭 이동 확인」이 있는데, /// 설정 파일이 없으면 실 로그인이 물리적으로 불가능하다. 그래서 목 모드에서만 /// 이 경로를 열어 둔다. /// /// **가드가 두 겹인 이유** — `!FirebaseBoot.isLive` 하나로는 부족하다. /// [FirebaseBoot.init] 은 모든 예외를 삼키고 목 모드로 내려가므로, 설정 파일이 /// 정상 투입된 릴리스 빌드라도 초기화가 어떤 이유로든 실패하면(플랫폼 초기화 실패, /// 리소스 패키징 오류, 기기 환경) 이 우회 입구가 배포 앱에 노출된다. /// 그래서 빌드 모드로 한 겹 더 막는다 — **릴리스 빌드에서는 어떤 경우에도 열리지 않는다.** bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; void signInWithMockSession() { if (!isMockSessionAvailable) return; _account = const AuthAccount( uid: 'mock-local', provider: null, displayName: '목 세션', isMock: true, ); notifyListeners(); } Future signOut() async { final currentProvider = _account?.provider; SocialAuthProvider? provider; if (currentProvider != null) { for (final candidate in providers) { if (candidate.provider == currentProvider) { provider = candidate; break; } } } try { await Future.wait([ if (provider != null) provider.signOut(), if (_session != null) _session.signOut(), ], eagerError: false); } finally { // 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다. _replaceAccount(null); } } 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(); } bool _sameAccount(AuthAccount? a, AuthAccount? b) => identical(a, b) || (a != null && b != null && a.uid == b.uid && a.provider == b.provider && a.displayName == b.displayName && a.email == b.email && a.isMock == b.isMock); @override void dispose() { final subscription = _sessionSubscription; _sessionSubscription = null; if (subscription != null) unawaited(subscription.cancel()); super.dispose(); } }