Files
fanuzu/app/lib/features/auth/auth_repository.dart
T

173 lines
6.2 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../core/firebase/firebase_boot.dart';
import 'auth.dart';
import 'social_providers.dart';
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
class AuthRepository extends ChangeNotifier {
AuthRepository({
List<SocialAuthProvider>? providers,
AuthSession? session,
bool? releaseMode,
}) : providers = providers ?? _defaultProviders(),
_session = session,
_account = session?.currentAccount,
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
_releaseMode = releaseMode ?? kReleaseMode {
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
}
final bool _releaseMode;
final AuthSession? _session;
StreamSubscription<AuthAccount?>? _sessionSubscription;
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
final List<SocialAuthProvider> providers;
AuthAccount? _account;
AuthAccount? get account => _account;
bool get isSignedIn => _account != null;
/// 정책 설계서 §P-01 확정 5종. **순서가 화면 버튼 순서다.**
///
/// 네이버가 빠지고 이메일·LINE 이 들어왔다(답변서 Q7). 카카오·LINE 은 개발자
/// 콘솔 앱 등록에 발주사 사업자 정보가 필요해 아직 키가 없다.
static List<SocialAuthProvider> _defaultProviders() => [
const EmailPasswordProvider(),
const GoogleSignInProvider(),
AppleSignInProvider(
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
),
const KeylessSocialProvider(AuthProviderKind.kakao, [
'카카오 네이티브 앱 키',
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
]),
lineProvider,
];
/// 이메일 제공자. 화면이 입력 폼을 띄우기 전에 있는지 확인한다.
EmailAuthProvider? get emailProvider {
for (final p in providers) {
if (p is EmailAuthProvider) return p;
}
return null;
}
/// 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다.
Future<AuthOutcome> signInWithEmail({
required String email,
required String password,
bool signUp = false,
}) async {
final impl = emailProvider;
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<AuthOutcome> signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider);
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<void> 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;
_account = 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();
}
}