Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스

This commit is contained in:
Sn-Kinos
2026-08-10 01:02:03 +09:00
parent fbba4405c3
commit 233ef99837
113 changed files with 8549 additions and 2 deletions
+102
View File
@@ -0,0 +1,102 @@
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
///
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** 카카오·네이버는 발주사 사업자 정보로
/// 개발자 콘솔에 앱을 등록해야 키가 나오고(Q3), Google·Apple 은 Firebase 설정 파일이
/// 있어야 한다(S1·S2). 둘 다 "아직 준비가 안 됐다"이지 "지원하지 않는다"가 아니므로
/// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다.
library;
/// 소셜 제공자. **순서와 라벨은 시안 `시안/screens/S-A-LOGIN.html` 의 버튼 그대로다.**
/// 화면명세 `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md` 도 4종으로 같다.
enum SocialProvider {
kakao('카카오로 시작하기'),
naver('네이버로 시작하기'),
apple('Apple로 시작하기'),
google('Google로 시작하기');
const SocialProvider(this.label);
/// 버튼 라벨. 임의로 바꾸지 마라 — 시안 대조 대상이다.
final String label;
}
// 이메일 가입에 대하여 — 기능 플래그를 두지 않았다.
//
// CD-18(가입 수단) 미확정(2주차_확인요청항목 Q2)이라 이메일 경로는 구현하지 않았다.
// 플래그 상수를 하나 선언해 두는 방법도 있었지만, 그 값을 읽는 분기가 없으면
// 「false 라서 닫혀 있다」는 착각만 만든다 — 누군가 true 로 바꿔도 아무 일이 일어나지 않는다.
// 지금 이메일을 닫고 있는 것은 플래그가 아니라 AuthRepository 의 제공자 목록에
// 이메일 구현체가 없다는 사실 하나다.
//
// Q2 가 「이메일 병행」으로 오면 할 일: [SocialAuthProvider] 를 구현한 이메일 제공자를 만들고
// AuthRepository._defaultProviders() 에 추가한다. 화면 수·약관 동선도 함께 바뀐다.
/// 로그인된 계정.
class AuthAccount {
const AuthAccount({
required this.uid,
required this.provider,
this.displayName,
this.email,
this.isMock = false,
});
final String uid;
final SocialProvider? provider;
final String? displayName;
final String? email;
/// Firebase 미설정 상태에서 만든 임시 세션인지. 실 세션과 절대 섞이면 안 된다.
final bool isMock;
}
/// 로그인 시도 결과.
sealed class AuthOutcome {
const AuthOutcome();
}
final class AuthSucceeded extends AuthOutcome {
const AuthSucceeded(this.account);
final AuthAccount account;
}
/// 사용자가 취소함. 에러로 취급하지 않는다.
final class AuthCancelled extends AuthOutcome {
const AuthCancelled();
}
/// 실패. [code] 는 `wireframe/planning/error-catalog.yaml` 의 ID 다(소셜 실패 = E-AUTH-03).
final class AuthFailed extends AuthOutcome {
const AuthFailed(this.code, this.message);
final String code;
final String message;
}
/// 아직 쓸 수 없음 — 키·설정 파일 미투입.
final class AuthNotConfigured extends AuthOutcome {
const AuthNotConfigured(this.provider, this.missing);
final SocialProvider provider;
/// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다.
final List<String> missing;
}
/// 소셜 로그인 제공자 인터페이스.
///
/// 이메일 가입이 확정되면(Q2) 같은 인터페이스로 구현체 하나가 더 붙는 자리다.
abstract interface class SocialAuthProvider {
SocialProvider get provider;
/// 실 연동 코드 경로를 가지고 있는지. 스텁이면 false.
bool get isLive;
/// 비어 있지 않으면 지금은 로그인할 수 없다. [signIn] 은 [AuthNotConfigured] 를 낸다.
List<String> get missingConfig;
Future<AuthOutcome> signIn();
Future<void> signOut();
}
@@ -0,0 +1,80 @@
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, bool? releaseMode})
: providers = providers ?? _defaultProviders(),
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
_releaseMode = releaseMode ?? kReleaseMode;
final bool _releaseMode;
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
final List<SocialAuthProvider> providers;
AuthAccount? _account;
AuthAccount? get account => _account;
bool get isSignedIn => _account != null;
static List<SocialAuthProvider> _defaultProviders() => const [
KeylessSocialProvider(SocialProvider.kakao, [
'카카오 네이티브 앱 키',
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
]),
KeylessSocialProvider(SocialProvider.naver, [
'네이버 client ID / client secret',
'네이버 개발자센터 앱 등록(사업자 정보 필요)',
]),
AppleSignInProvider(),
GoogleSignInProvider(),
// 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다.
// 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고.
];
Future<AuthOutcome> signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider);
final outcome = await impl.signIn();
if (outcome is AuthSucceeded) {
_account = outcome.account;
notifyListeners();
}
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 {
for (final p in providers) {
await p.signOut();
}
_account = null;
notifyListeners();
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../../core/firebase/firebase_boot.dart';
import '../../core/theme/tokens.dart';
import 'auth.dart';
import 'auth_repository.dart';
/// S-A-LOGIN 로그인.
///
/// 시안: `시안/screens/S-A-LOGIN.html` · 명세: `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md`
/// 골격 범위라 4개 버튼과 결과 안내까지만 구현했다. 약관 동의(S-A-TERMS) 이후 동선은 3주차다.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key, required this.auth});
final AuthRepository auth;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
String? _notice;
bool _busy = false;
Future<void> _signIn(SocialProvider provider) async {
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider);
if (!mounted) return;
setState(() {
_busy = false;
_notice = switch (outcome) {
AuthSucceeded() => null,
AuthCancelled() => null,
AuthFailed(:final code, :final message) => '[$code] $message',
AuthNotConfigured(:final provider, :final missing) =>
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
'필요한 항목: ${missing.join(' · ')}',
};
});
}
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpace.s5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppSpace.s6),
// 로고 원본은 미확정(Q6)이라 워드마크를 텍스트로 둔다. 확정되면 이미지로 교체.
Text('Fancake', style: text.displayMedium),
const SizedBox(height: AppSpace.s4),
Text('당신의 팬 활동을\n하나의 행성으로', style: text.displayLarge),
const SizedBox(height: AppSpace.s4),
Text(
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
style: text.bodyMedium?.copyWith(color: AppColors.onCosmicVariant),
),
const SizedBox(height: AppSpace.s7),
Text('계정으로 시작하기', style: text.titleLarge),
const SizedBox(height: AppSpace.s2),
Text(
'사용 중인 계정 하나를 선택해 주세요.',
style: text.bodySmall,
),
const SizedBox(height: AppSpace.s4),
for (final p in widget.auth.providers) ...[
_ProviderButton(
provider: p.provider,
enabled: !_busy,
onPressed: () => _signIn(p.provider),
),
const SizedBox(height: AppSpace.s3),
],
if (_notice != null) ...[
const SizedBox(height: AppSpace.s2),
_Notice(_notice!),
],
if (widget.auth.isMockSessionAvailable) ...[
const SizedBox(height: AppSpace.s5),
const Divider(),
const SizedBox(height: AppSpace.s4),
_MockBanner(onEnter: widget.auth.signInWithMockSession),
],
const SizedBox(height: AppSpace.s6),
],
),
),
),
);
}
}
/// 소셜 버튼. 색과 라벨은 각 사 브랜드 가이드 고정값이라 테마를 타지 않는다.
class _ProviderButton extends StatelessWidget {
const _ProviderButton({
required this.provider,
required this.enabled,
required this.onPressed,
});
final SocialProvider provider;
final bool enabled;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final (fill, label, stroke) = switch (provider) {
SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null),
SocialProvider.naver => (BrandColors.naver, BrandColors.naverLabel, null),
SocialProvider.apple => (
BrandColors.apple,
BrandColors.appleLabel,
BrandColors.appleOnlight,
),
SocialProvider.google => (
BrandColors.googleFill,
BrandColors.googleLabel,
BrandColors.googleStroke,
),
};
return FilledButton(
onPressed: enabled ? onPressed : null,
style: FilledButton.styleFrom(
backgroundColor: fill,
foregroundColor: label,
disabledBackgroundColor: fill.withValues(alpha: 0.4),
side: stroke == null ? null : BorderSide(color: stroke),
),
child: Text(provider.label),
);
}
}
class _Notice extends StatelessWidget {
const _Notice(this.message);
final String message;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
border: Border.all(color: AppColors.cosmicOutline),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(message, style: Theme.of(context).textTheme.bodySmall),
);
}
/// Firebase 미설정 안내 + 탭 이동 시연 입구.
class _MockBanner extends StatelessWidget {
const _MockBanner({required this.onEnter});
final VoidCallback onEnter;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('목(mock) 모드', style: text.labelMedium),
const SizedBox(height: AppSpace.s2),
Text(FirebaseBoot.reason, style: text.bodySmall),
const SizedBox(height: AppSpace.s3),
OutlinedButton(
onPressed: onEnter,
child: const Text('목 세션으로 탭 둘러보기'),
),
],
);
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:google_sign_in/google_sign_in.dart' as gsi;
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
import '../../core/firebase/firebase_boot.dart';
import 'auth.dart';
/// Google 로그인 — 실 코드 경로.
///
/// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다.
/// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다.
final class GoogleSignInProvider implements SocialAuthProvider {
const GoogleSignInProvider();
@override
SocialProvider get provider => SocialProvider.google;
@override
bool get isLive => true;
// 화면에 그대로 노출되는 목록이라 리포 경로가 아니라 사람이 읽는 이름을 쓴다.
// 실제 파일 경로는 FirebaseBoot.detail 로 로그에만 남는다.
@override
List<String> get missingConfig =>
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
@override
Future<AuthOutcome> signIn() async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
final signIn = gsi.GoogleSignIn.instance;
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
await signIn.initialize();
final account = await signIn.authenticate();
final idToken = account.authentication.idToken;
if (idToken == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
final result = await fb.FirebaseAuth.instance.signInWithCredential(credential);
return AuthSucceeded(_toAccount(result.user, provider));
} on gsi.GoogleSignInException catch (error) {
if (error.code == gsi.GoogleSignInExceptionCode.canceled) {
return const AuthCancelled();
}
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
}
}
@override
Future<void> signOut() async {
if (!FirebaseBoot.isLive) return;
await gsi.GoogleSignIn.instance.signOut();
await fb.FirebaseAuth.instance.signOut();
}
}
/// Apple 로그인 — 실 코드 경로.
///
/// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
final class AppleSignInProvider implements SocialAuthProvider {
const AppleSignInProvider();
@override
SocialProvider get provider => SocialProvider.apple;
@override
bool get isLive => true;
@override
List<String> get missingConfig => FirebaseBoot.isLive
? const []
: [...FirebaseBoot.missingLabels, 'Apple 개발자 설정'];
@override
Future<AuthOutcome> signIn() async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
if (!await apple.SignInWithApple.isAvailable()) {
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
}
try {
final credential = await apple.SignInWithApple.getAppleIDCredential(
scopes: const [
apple.AppleIDAuthorizationScopes.email,
apple.AppleIDAuthorizationScopes.fullName,
],
);
final idToken = credential.identityToken;
if (idToken == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final oauth = fb.OAuthProvider('apple.com').credential(
idToken: idToken,
accessToken: credential.authorizationCode,
);
final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth);
return AuthSucceeded(_toAccount(result.user, provider));
} on apple.SignInWithAppleAuthorizationException catch (error) {
if (error.code == apple.AuthorizationErrorCode.canceled) {
return const AuthCancelled();
}
return AuthFailed('E-AUTH-03', error.message);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
}
}
@override
Future<void> signOut() async {
if (!FirebaseBoot.isLive) return;
await fb.FirebaseAuth.instance.signOut();
}
}
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버.
///
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다.
/// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다.
final class KeylessSocialProvider implements SocialAuthProvider {
const KeylessSocialProvider(this.provider, this.missingConfig);
@override
final SocialProvider provider;
@override
final List<String> missingConfig;
@override
bool get isLive => false;
@override
Future<AuthOutcome> signIn() async => AuthNotConfigured(provider, missingConfig);
@override
Future<void> signOut() async {}
}
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
uid: user?.uid ?? '',
provider: provider,
displayName: user?.displayName,
email: user?.email,
);