302 lines
12 KiB
Dart
302 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../core/firebase/firebase_boot.dart';
|
|
import 'auth.dart';
|
|
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,
|
|
_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;
|
|
final AuthSession? _session;
|
|
final EmailAuthProvider _qaEmail;
|
|
StreamSubscription<AuthAccount?>? _sessionSubscription;
|
|
|
|
/// 로그인 화면에 그릴 제공자 목록. **여기 없는 수단은 사용자가 닿을 수 없다.**
|
|
final List<SocialAuthProvider> providers;
|
|
|
|
AuthAccount? _account;
|
|
AuthAccount? get account => _account;
|
|
bool get isSignedIn => _account != null;
|
|
|
|
/// 간편 로그인 「회원가입」 모드로 막 인증을 마쳤다. 라우터가 이 값을 보고 가입 완료
|
|
/// 화면(`182:29344`)을 한 번 끼운다. [finishSignup] 이 내린다.
|
|
bool get justSignedUp => _justSignedUp;
|
|
bool _justSignedUp = false;
|
|
|
|
void finishSignup() {
|
|
if (!_justSignedUp) return;
|
|
_justSignedUp = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// **별첨 B v1.4 확정 4종.** 순서는 회신 문구 순서다(화면 버튼은 Figma 순서로 다시 줄 세운다).
|
|
///
|
|
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
|
|
/// > X 로그인은 제외합니다.
|
|
///
|
|
/// **이메일은 여기 없다.** 목록에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면
|
|
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
|
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
|
///
|
|
/// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC
|
|
/// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를
|
|
/// 돌려준다.
|
|
static List<SocialAuthProvider> _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·email(이메일 권한 신청 승인 후).
|
|
OidcSignInProvider(
|
|
AuthProviderKind.line,
|
|
providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!,
|
|
scopes: const ['profile', 'email'],
|
|
// LINE 앱 자동 로그인으로 넘어가면 콜백이 Custom Tab 밖에서 열려 로그인이
|
|
// 깨진다(Android). LINE Login 공식 파라미터로 끈다.
|
|
androidParameters: const {'disable_auto_login': 'true'},
|
|
),
|
|
];
|
|
|
|
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
|
|
///
|
|
/// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 그래도 발주사가
|
|
/// 로그인 이후 화면을 확인하려면 실 계정 하나로 들어갈 수단이 필요해 코드를
|
|
/// 남겼고, 릴리스 빌드에서는 null 이라 부를 수 있는 경로 자체가 없다.
|
|
///
|
|
/// [isMockSessionAvailable] 과 같은 이유로 빌드 모드로 막는다 — 릴리스
|
|
/// 산출물에 내부 우회 입구가 새어 나가면 안 된다.
|
|
EmailAuthProvider? get qaEmailProvider => _releaseMode ? null : _qaEmail;
|
|
|
|
/// QA 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다.
|
|
Future<AuthOutcome> signInWithEmail({
|
|
required String email,
|
|
required String password,
|
|
bool signUp = false,
|
|
}) async {
|
|
final impl = qaEmailProvider;
|
|
if (impl == null) {
|
|
return AuthFailed('E-AUTH-03', appL10n.authEmailUnavailable);
|
|
}
|
|
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 AuthFailed('E-AUTH-01', appL10n.authCheckCredentials);
|
|
}
|
|
if (outcome is AuthSucceeded) _replaceAccount(outcome.account);
|
|
return outcome;
|
|
}
|
|
|
|
Future<AuthOutcome> signIn(
|
|
SocialProvider provider, {
|
|
bool signUp = false,
|
|
}) 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 = AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
|
|
}
|
|
if (outcome is AuthSucceeded) {
|
|
_justSignedUp = signUp;
|
|
_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<void> signOut() async {
|
|
final currentProvider = _account?.provider;
|
|
SocialAuthProvider? provider;
|
|
if (currentProvider != null) {
|
|
for (final candidate in providers) {
|
|
if (candidate.provider == currentProvider) {
|
|
provider = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// SDK 로그아웃(Google · Kakao 등)은 기다리지 않는다. 로그인 상태를 정하는 것은 Firebase 세션이고,
|
|
// SDK 쪽은 다음 로그인의 자동 선택을 지우는 정리일 뿐이다. Android Google 은 Credential Manager
|
|
// `clearCredentialStateAsync` 가 응답 없이 멈춰 3초 시간 제한을 다 쓰고, 그 TimeoutException 이
|
|
// 처리되지 않은 채 로그에 남았다(10/6 실기기). 실패는 로그로만 남긴다.
|
|
if (provider != null) {
|
|
unawaited(
|
|
provider.signOut().catchError((Object e) {
|
|
debugPrint('[Auth] provider sign-out skipped: $e');
|
|
}),
|
|
);
|
|
}
|
|
try {
|
|
await _session?.signOut();
|
|
} finally {
|
|
// 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
|
|
_replaceAccount(null);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|