가입 수단 5종(이메일·Google·Apple·Kakao·LINE) · 로그인 에뮬레이터 연결 · 최소 지원 Android 10

This commit is contained in:
Sn-Kinos
2026-09-01 01:03:56 +09:00
parent 1d462ebe31
commit 304ad105d6
14 changed files with 770 additions and 54 deletions
+36
View File
@@ -96,3 +96,39 @@ iOS 플러그인이 전부 Swift Package 로 옮겨졌는데 프로젝트에 Coc
동작에는 지장이 없으나 빌드 시간이 늘고 구성이 이중이다. `pod deintegrate` 와 동작에는 지장이 없으나 빌드 시간이 늘고 구성이 이중이다. `pod deintegrate` 와
`Debug.xcconfig` · `Release.xcconfig` 의 Pods include 제거가 필요하다. `Debug.xcconfig` · `Release.xcconfig` 의 Pods include 제거가 필요하다.
**11주차 최적화 구간에서 정리한다.** **11주차 최적화 구간에서 정리한다.**
## 서버 없이 로그인을 돌려 보기
실 Firebase 프로젝트는 계정 명의가 확정되어야 열 수 있다.
그 전까지는 **에뮬레이터를 물려 실제 왕복을 돈다.** 설정 파일 대신 에뮬레이터를 붙이는
것이라, 이 절차가 통과하면 설정 파일이 들어오는 날 코드 수정 없이 동작한다.
### 1. 에뮬레이터 기동
```
cd app/firebase
firebase emulators:start --only auth --project demo-fanuzu
```
**JDK 21 이상이 필요하다.** firebase-tools 15 가 그 아래를 거부한다. Gradle 은 Temurin 17 을
쓰므로 둘이 다르며, 에뮬레이터를 띄울 때만 `JAVA_HOME` 을 21+ 로 바꾼다.
### 2. 앱을 에뮬레이터에 붙이기
```
flutter run --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 # Android 에뮬레이터
flutter run --dart-define=FANUZU_EMULATOR_HOST=127.0.0.1 # iOS 시뮬레이터
```
`10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다.
### 평문 HTTP 예외에 대하여
에뮬레이터는 평문 HTTP 로 뜨는데 Android 9 부터 평문이 기본 차단이다. 예외를
`android/app/src/{debug,profile}/` 에만 두었으므로 **릴리스 빌드에는 들어가지 않는다.**
확인:
```
unzip -p build/app/outputs/flutter-apk/app-release.apk AndroidManifest.xml \
| strings | grep -c networkSecurityConfig # 0 이어야 한다
```
+7 -3
View File
@@ -43,9 +43,13 @@ android {
// 잠정값. 스토어 등록 명의(2주차_확인요청항목 Q5)가 발주사 명의로 확정되면 교체한다(Q14). // 잠정값. 스토어 등록 명의(2주차_확인요청항목 Q5)가 발주사 명의로 확정되면 교체한다(Q14).
// 스토어에 한 번 올리면 변경 불가 — 첫 제출 전에 확정해야 한다. // 스토어에 한 번 올리면 변경 불가 — 첫 제출 전에 확정해야 한다.
applicationId = fancakeApplicationId applicationId = fancakeApplicationId
// You can update the following values to match your application needs. // **계약값이다.** 별첨 1 v1.0 이 최소 지원 OS 를 Android 10(API 29)으로 적었고,
// For more information, see: https://flutter.dev/to/review-gradle-config. // 근거는 2주차 플러그인 선검증 리포트의 바이너리 실측이다.
minSdk = flutter.minSdkVersion //
// Flutter 기본값(API 24)을 그대로 두면 우리가 검증하지 않은 기기까지 설치가
// 열린다 — 스토어에 올라간 뒤에는 그 기기의 크래시가 우리 책임이 된다.
// 플러그인을 늘릴 때 이 값을 재계산하는 절차는 선검증 리포트 §3.5 에 있다.
minSdk = 29
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@@ -1,7 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Firebase 에뮬레이터(평문 HTTP) 연결 허용. 이 소스 세트에만 있으므로
릴리스 빌드에는 들어가지 않는다. -->
<application
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:networkSecurityConfig" />
</manifest> </manifest>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Firebase 에뮬레이터는 평문 HTTP 로 뜬다. Android 9 부터 평문이 기본 차단이라
이 예외가 없으면 에뮬레이터 연결이 「내부 오류」로만 보인다.
**debug · profile 소스 세트에만 둔다.** release 매니페스트는 손대지 않으므로
배포 앱에서는 평문이 그대로 막힌다 — 예외를 릴리스에 흘리면 중간자 공격 표면이
열린다.
주소 셋만 연다.
- 10.0.2.2 Android 에뮬레이터에서 본 호스트 PC
- 127.0.0.1 기기 자신 (adb reverse 로 포워딩할 때)
- localhost 같음
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
@@ -1,7 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Firebase 에뮬레이터(평문 HTTP) 연결 허용. 이 소스 세트에만 있으므로
릴리스 빌드에는 들어가지 않는다. -->
<application
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:networkSecurityConfig" />
</manifest> </manifest>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Firebase 에뮬레이터는 평문 HTTP 로 뜬다. Android 9 부터 평문이 기본 차단이라
이 예외가 없으면 에뮬레이터 연결이 「내부 오류」로만 보인다.
**debug · profile 소스 세트에만 둔다.** release 매니페스트는 손대지 않으므로
배포 앱에서는 평문이 그대로 막힌다 — 예외를 릴리스에 흘리면 중간자 공격 표면이
열린다.
주소 셋만 연다.
- 10.0.2.2 Android 에뮬레이터에서 본 호스트 PC
- 127.0.0.1 기기 자신 (adb reverse 로 포워딩할 때)
- localhost 같음
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
+41
View File
@@ -1,3 +1,4 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -43,7 +44,32 @@ abstract final class FirebaseBoot {
/// 화면에 보여 줄 미투입 항목 이름. 내부 경로를 담지 않는다. /// 화면에 보여 줄 미투입 항목 이름. 내부 경로를 담지 않는다.
static const List<String> missingLabels = ['Firebase 설정']; static const List<String> missingLabels = ['Firebase 설정'];
/// 에뮬레이터 호스트. `--dart-define=FANUZU_EMULATOR_HOST=127.0.0.1` 로 켠다.
///
/// **왜 두는가** — 실 Firebase 프로젝트가 열리기 전에도 로그인이
/// 실제로 도는지 확인할 수 있어야 한다.
///
/// 릴리스 빌드에서는 열리지 않는다. 배포된 앱이 로컬 호스트를 바라보면
/// 아무것도 동작하지 않고 원인도 보이지 않는다.
static const String emulatorHost =
String.fromEnvironment('FANUZU_EMULATOR_HOST');
static bool get useEmulator => emulatorHost.isNotEmpty && !kReleaseMode;
/// 에뮬레이터 전용 설정. 실 프로젝트 값이 아니며 비밀값도 아니다.
/// `demo-` 로 시작하는 프로젝트 ID 는 에뮬레이터가 외부 접속을 막는다.
static const FirebaseOptions _emulatorOptions = FirebaseOptions(
apiKey: 'demo-key',
appId: '1:0:android:0',
messagingSenderId: '0',
projectId: 'demo-fanuzu',
);
static Future<void> init() async { static Future<void> init() async {
if (useEmulator) {
await _initEmulator();
return;
}
try { try {
await Firebase.initializeApp(); await Firebase.initializeApp();
_mode = FirebaseMode.live; _mode = FirebaseMode.live;
@@ -59,6 +85,21 @@ abstract final class FirebaseBoot {
if (_detail != null) debugPrint('[FirebaseBoot] detail :: $_detail'); if (_detail != null) debugPrint('[FirebaseBoot] detail :: $_detail');
} }
static Future<void> _initEmulator() async {
try {
await Firebase.initializeApp(options: _emulatorOptions);
FirebaseAuth.instance.useAuthEmulator(emulatorHost, 9099);
_mode = FirebaseMode.live;
_reason = '로컬 에뮬레이터에 연결했습니다. 실 서버가 아닙니다.';
_detail = 'emulator host: $emulatorHost';
} on Object catch (error) {
_mode = FirebaseMode.mock;
_reason = '에뮬레이터에 연결하지 못해 임시(목) 모드로 동작 중입니다.';
_detail = '원인: $error';
}
debugPrint('[FirebaseBoot] mode=${_mode.name} :: $_reason');
}
/// 테스트에서 모드를 고정할 때만 쓴다. /// 테스트에서 모드를 고정할 때만 쓴다.
@visibleForTesting @visibleForTesting
static void debugSetMode(FirebaseMode mode, {String reason = '테스트 지정'}) { static void debugSetMode(FirebaseMode mode, {String reason = '테스트 지정'}) {
+7
View File
@@ -49,8 +49,15 @@ abstract final class AppColors {
abstract final class BrandColors { abstract final class BrandColors {
static const kakao = Color(0xFFFEE500); // brand-kakao #FEE500 static const kakao = Color(0xFFFEE500); // brand-kakao #FEE500
static const kakaoLabel = Color(0xFF000000); // brand-kakao-label #000000 static const kakaoLabel = Color(0xFF000000); // brand-kakao-label #000000
// 네이버는 확정 가입 수단 5종에서 빠졌다(정책 §P-01 · 답변서 Q7). 토큰은
// 남겨 둔다 — 발주사께서 다시 넣으시면 그대로 쓴다.
static const naver = Color(0xFF03C75A); // brand-naver #03C75A static const naver = Color(0xFF03C75A); // brand-naver #03C75A
static const naverLabel = Color(0xFFFFFFFF); // brand-naver-label #FFFFFF static const naverLabel = Color(0xFFFFFFFF); // brand-naver-label #FFFFFF
static const line = Color(0xFF06C755); // brand-line #06C755
static const lineLabel = Color(0xFFFFFFFF); // brand-line-label #FFFFFF
// 이메일은 브랜드가 없다. 앱 강조색을 그대로 쓴다.
static const email = AppColors.cosmicAccent;
static const emailLabel = AppColors.onCosmicAccent;
static const apple = Color(0xFF000000); // brand-apple #000000 static const apple = Color(0xFF000000); // brand-apple #000000
static const appleLabel = Color(0xFFFFFFFF); // brand-apple-label #FFFFFF static const appleLabel = Color(0xFFFFFFFF); // brand-apple-label #FFFFFF
static const appleOnlight = Color(0xFFFFFFFF); // brand-apple-onlight #FFFFFF static const appleOnlight = Color(0xFFFFFFFF); // brand-apple-onlight #FFFFFF
+36 -22
View File
@@ -6,30 +6,32 @@
/// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다. /// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다.
library; library;
/// 소셜 제공자. **순서와 라벨은 시안 `시안/screens/S-A-LOGIN.html` 의 버튼 그대로다.** /// 가입 수단 — **정책 설계서 §P-01 확정값 5종**(답변서 v1.1 Q7).
/// 화면명세 `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md` 도 4종으로 같다. ///
enum SocialProvider { /// 초안 당시 CD-18 이 미확정이라 소셜 4종(카카오·네이버·Apple·Google)만 두었는데,
/// 답변서가 **이메일 · Google · Apple · Kakao · LINE** 으로 확정했다.
/// **네이버는 확정 목록에 없고, 이메일과 LINE 이 새로 들어왔다.**
///
/// 앱 Figma 에는 WeChat · X 버튼도 그려져 있으나 답변서가 5종으로 정했으므로
/// 1차는 5종이다. 추가 시 재작업이 없도록 제공자를 인터페이스 뒤에 둔다.
enum AuthProviderKind {
email('이메일로 시작하기'),
kakao('카카오로 시작하기'), kakao('카카오로 시작하기'),
naver('네이버로 시작하기'), line('LINE으로 시작하기'),
apple('Apple로 시작하기'), apple('Apple로 시작하기'),
google('Google로 시작하기'); google('Google로 시작하기');
const SocialProvider(this.label); const AuthProviderKind(this.label);
/// 버튼 라벨. 임의로 바꾸지 마라 — 시안 대조 대상이다. /// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다.
final String label; final String label;
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. 화면이 이걸로 갈린다.
bool get needsForm => this == AuthProviderKind.email;
} }
// 이메일 가입에 대하여 — 기능 플래그를 두지 않았다. /// 이전 이름. 한 번에 다 바꾸지 않아도 되게 남겨 둔다.
// typedef SocialProvider = AuthProviderKind;
// CD-18(가입 수단) 미확정(2주차_확인요청항목 Q2)이라 이메일 경로는 구현하지 않았다.
// 플래그 상수를 하나 선언해 두는 방법도 있었지만, 그 값을 읽는 분기가 없으면
// 「false 라서 닫혀 있다」는 착각만 만든다 — 누군가 true 로 바꿔도 아무 일이 일어나지 않는다.
// 지금 이메일을 닫고 있는 것은 플래그가 아니라 AuthRepository 의 제공자 목록에
// 이메일 구현체가 없다는 사실 하나다.
//
// Q2 가 「이메일 병행」으로 오면 할 일: [SocialAuthProvider] 를 구현한 이메일 제공자를 만들고
// AuthRepository._defaultProviders() 에 추가한다. 화면 수·약관 동선도 함께 바뀐다.
/// 로그인된 계정. /// 로그인된 계정.
class AuthAccount { class AuthAccount {
@@ -42,7 +44,7 @@ class AuthAccount {
}); });
final String uid; final String uid;
final SocialProvider? provider; final AuthProviderKind? provider;
final String? displayName; final String? displayName;
final String? email; final String? email;
@@ -78,17 +80,15 @@ final class AuthFailed extends AuthOutcome {
final class AuthNotConfigured extends AuthOutcome { final class AuthNotConfigured extends AuthOutcome {
const AuthNotConfigured(this.provider, this.missing); const AuthNotConfigured(this.provider, this.missing);
final SocialProvider provider; final AuthProviderKind provider;
/// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다. /// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다.
final List<String> missing; final List<String> missing;
} }
/// 소셜 로그인 제공자 인터페이스. /// 로그인 제공자 인터페이스. 이메일도 같은 인터페이스를 쓴다.
///
/// 이메일 가입이 확정되면(Q2) 같은 인터페이스로 구현체 하나가 더 붙는 자리다.
abstract interface class SocialAuthProvider { abstract interface class SocialAuthProvider {
SocialProvider get provider; AuthProviderKind get provider;
/// 실 연동 코드 경로를 가지고 있는지. 스텁이면 false. /// 실 연동 코드 경로를 가지고 있는지. 스텁이면 false.
bool get isLive; bool get isLive;
@@ -101,6 +101,20 @@ abstract interface class SocialAuthProvider {
Future<void> signOut(); Future<void> signOut();
} }
/// 이메일 제공자만 쓰는 확장 계약.
///
/// 소셜은 외부 창이 열리고 끝이지만 이메일은 **앱 안에서 값을 받아야 한다.**
/// [SocialAuthProvider.signIn] 하나로 묶으면 화면이 제공자 종류를 보고 분기하게
/// 되므로, 입력이 필요한 쪽만 별도 계약으로 뺀다.
abstract interface class EmailAuthProvider implements SocialAuthProvider {
Future<AuthOutcome> signInWithEmail(String email, String password);
Future<AuthOutcome> signUpWithEmail(String email, String password);
/// 비밀번호 재설정 메일 발송. Figma 「비밀번호 재설정」 화면이 이걸 부른다.
Future<AuthOutcome> sendPasswordReset(String email);
}
/// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계. /// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계.
/// ///
/// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에 /// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에
+42 -11
View File
@@ -32,23 +32,54 @@ class AuthRepository extends ChangeNotifier {
AuthAccount? get account => _account; AuthAccount? get account => _account;
bool get isSignedIn => _account != null; bool get isSignedIn => _account != null;
/// 정책 설계서 §P-01 확정 5종. **순서가 화면 버튼 순서다.**
///
/// 네이버가 빠지고 이메일·LINE 이 들어왔다(답변서 Q7). 카카오·LINE 은 개발자
/// 콘솔 앱 등록에 발주사 사업자 정보가 필요해 아직 키가 없다.
static List<SocialAuthProvider> _defaultProviders() => [ static List<SocialAuthProvider> _defaultProviders() => [
const KeylessSocialProvider(SocialProvider.kakao, [ const EmailPasswordProvider(),
'카카오 네이티브 앱 키', const GoogleSignInProvider(),
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
]),
const KeylessSocialProvider(SocialProvider.naver, [
'네이버 client ID / client secret',
'네이버 개발자센터 앱 등록(사업자 정보 필요)',
]),
AppleSignInProvider( AppleSignInProvider(
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
), ),
const GoogleSignInProvider(), const KeylessSocialProvider(AuthProviderKind.kakao, [
// 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다. '카카오 네이티브 앱 키',
// 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고. '카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
]),
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 { Future<AuthOutcome> signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider); final impl = providers.firstWhere((p) => p.provider == provider);
late final AuthOutcome outcome; late final AuthOutcome outcome;
@@ -40,8 +40,9 @@ SocialProvider? _providerOf(fb.User user) {
if (ids.any((id) => id.toLowerCase().contains('kakao'))) { if (ids.any((id) => id.toLowerCase().contains('kakao'))) {
return SocialProvider.kakao; return SocialProvider.kakao;
} }
if (ids.any((id) => id.toLowerCase().contains('naver'))) { if (ids.any((id) => id.toLowerCase().contains('line'))) {
return SocialProvider.naver; return AuthProviderKind.line;
} }
if (ids.contains('password')) return AuthProviderKind.email;
return null; return null;
} }
+193 -8
View File
@@ -5,10 +5,13 @@ import '../../core/theme/tokens.dart';
import 'auth.dart'; import 'auth.dart';
import 'auth_repository.dart'; import 'auth_repository.dart';
/// S-A-LOGIN 로그인. /// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`).
/// ///
/// 시안: `시안/screens/S-A-LOGIN.html` · 명세: `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md` /// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google ·
/// 골격 범위라 4개 버튼과 결과 안내까지만 구현했다. 약관 동의(S-A-TERMS) 이후 동선은 3주차다. /// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는
/// 버튼 하나로 외부 창을 연다.
///
/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다.
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key, required this.auth}); const LoginScreen({super.key, required this.auth});
@@ -19,16 +22,62 @@ class LoginScreen extends StatefulWidget {
} }
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _form = GlobalKey<FormState>();
String? _notice; String? _notice;
bool _busy = false; bool _busy = false;
Future<void> _signIn(SocialProvider provider) async { /// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다.
bool _emailOpen = false;
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
bool _signUp = false;
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submitEmail() async {
if (!(_form.currentState?.validate() ?? false)) return;
setState(() => _busy = true); setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider); final outcome = await widget.auth.signInWithEmail(
email: _email.text,
password: _password.text,
signUp: _signUp,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_busy = false; _busy = false;
_notice = switch (outcome) { _notice = _noticeOf(outcome);
});
}
Future<void> _resetPassword() async {
final email = _email.text.trim();
if (email.isEmpty) {
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.');
return;
}
final impl = widget.auth.emailProvider;
if (impl == null) return;
setState(() => _busy = true);
final outcome = await impl.sendPasswordReset(email);
if (!mounted) return;
setState(() {
_busy = false;
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
_notice = outcome is AuthFailed
? _noticeOf(outcome)
: '입력하신 주소로 재설정 안내를 보냈습니다.';
});
}
String? _noticeOf(AuthOutcome outcome) => switch (outcome) {
AuthSucceeded() => null, AuthSucceeded() => null,
AuthCancelled() => null, AuthCancelled() => null,
AuthFailed(:final code, :final message) => '[$code] $message', AuthFailed(:final code, :final message) => '[$code] $message',
@@ -36,6 +85,14 @@ class _LoginScreenState extends State<LoginScreen> {
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n' '${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
'필요한 항목: ${missing.join(' · ')}', '필요한 항목: ${missing.join(' · ')}',
}; };
Future<void> _signIn(SocialProvider provider) async {
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider);
if (!mounted) return;
setState(() {
_busy = false;
_notice = _noticeOf(outcome);
}); });
} }
@@ -71,8 +128,23 @@ class _LoginScreenState extends State<LoginScreen> {
_ProviderButton( _ProviderButton(
provider: p.provider, provider: p.provider,
enabled: !_busy, enabled: !_busy,
onPressed: () => _signIn(p.provider), onPressed: p.provider.needsForm
? () => setState(() => _emailOpen = !_emailOpen)
: () => _signIn(p.provider),
), ),
if (p.provider.needsForm && _emailOpen) ...[
const SizedBox(height: AppSpace.s3),
_EmailForm(
formKey: _form,
email: _email,
password: _password,
signUp: _signUp,
busy: _busy,
onSubmit: _submitEmail,
onToggleMode: () => setState(() => _signUp = !_signUp),
onReset: _resetPassword,
),
],
const SizedBox(height: AppSpace.s3), const SizedBox(height: AppSpace.s3),
], ],
if (_notice != null) ...[ if (_notice != null) ...[
@@ -110,7 +182,8 @@ class _ProviderButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (fill, label, stroke) = switch (provider) { final (fill, label, stroke) = switch (provider) {
SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null), SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null),
SocialProvider.naver => (BrandColors.naver, BrandColors.naverLabel, null), SocialProvider.line => (BrandColors.line, BrandColors.lineLabel, null),
SocialProvider.email => (BrandColors.email, BrandColors.emailLabel, null),
SocialProvider.apple => ( SocialProvider.apple => (
BrandColors.apple, BrandColors.apple,
BrandColors.appleLabel, BrandColors.appleLabel,
@@ -176,3 +249,115 @@ class _MockBanner extends StatelessWidget {
); );
} }
} }
/// 이메일 입력 폼.
///
/// Figma 「로그인 입력 영역」 명세 —
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
/// *로그인 버튼 - 필수값 입력 시 활성화*
class _EmailForm extends StatelessWidget {
const _EmailForm({
required this.formKey,
required this.email,
required this.password,
required this.signUp,
required this.busy,
required this.onSubmit,
required this.onToggleMode,
required this.onReset,
});
final GlobalKey<FormState> formKey;
final TextEditingController email;
final TextEditingController password;
final bool signUp;
final bool busy;
final VoidCallback onSubmit;
final VoidCallback onToggleMode;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Form(
key: formKey,
child: Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(signUp ? '이메일로 가입' : '이메일로 로그인', style: t.titleMedium),
const SizedBox(height: AppSpace.s3),
TextFormField(
controller: email,
enabled: !busy,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(labelText: '이메일'),
validator: (value) {
final v = (value ?? '').trim();
if (v.isEmpty) return '이메일을 입력해 주세요.';
// 형식 검사는 최소한만 한다. 지나치게 엄격한 정규식은 유효한
// 주소를 막는다 — 실제 검증은 가입 확인 메일이 한다.
if (!v.contains('@') || v.startsWith('@') || v.endsWith('@')) {
return '이메일 형식이 올바르지 않습니다.';
}
return null;
},
),
const SizedBox(height: AppSpace.s3),
TextFormField(
controller: password,
enabled: !busy,
obscureText: true,
autofillHints: const [AutofillHints.password],
decoration: const InputDecoration(labelText: '비밀번호'),
validator: (value) {
final v = value ?? '';
if (v.isEmpty) return '비밀번호를 입력해 주세요.';
if (signUp && v.length < EmailFormMetrics.minPasswordLength) {
return '${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.';
}
return null;
},
),
const SizedBox(height: AppSpace.s4),
FilledButton(
onPressed: busy ? null : onSubmit,
child: Text(signUp ? '가입하기' : '로그인'),
),
const SizedBox(height: AppSpace.s2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: busy ? null : onToggleMode,
child: Text(signUp ? '이미 계정이 있어요' : '계정 만들기'),
),
if (!signUp)
TextButton(
onPressed: busy ? null : onReset,
child: const Text('비밀번호 재설정'),
),
],
),
],
),
),
);
}
}
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
abstract final class EmailFormMetrics {
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
/// 6자는 사전 공격에 사실상 무방비다.
static const minPasswordLength = 8;
}
+102 -1
View File
@@ -148,7 +148,7 @@ final class AppleSignInProvider implements SocialAuthProvider {
kIsWeb || defaultTargetPlatform == TargetPlatform.android; kIsWeb || defaultTargetPlatform == TargetPlatform.android;
} }
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버. /// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·LINE.
/// ///
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다. /// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다.
/// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다. /// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다.
@@ -197,3 +197,104 @@ AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
displayName: user?.displayName, displayName: user?.displayName,
email: user?.email, email: user?.email,
); );
/// 이메일 · 비밀번호 — 실 코드 경로.
///
/// 정책 설계서 §P-01 이 가입 수단 5종에 이메일을 넣어 확정했다(답변서 Q7).
/// 초안 당시 CD-18 미확정이라 비워 두었던 자리다.
///
/// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이
/// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가
/// 사라졌다」를 겪는다. Figma 도 로그인 화면과 회원가입 화면을 나눠 두었다.
final class EmailPasswordProvider implements EmailAuthProvider {
const EmailPasswordProvider();
@override
AuthProviderKind get provider => AuthProviderKind.email;
@override
bool get isLive => true;
@override
List<String> get missingConfig =>
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
/// 폼 없이 부르면 아무 일도 하지 않는다. 화면이 입력을 받아
/// [signInWithEmail] 을 부른다.
@override
Future<AuthOutcome> signIn() async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
return const AuthCancelled();
}
@override
Future<AuthOutcome> signInWithEmail(String email, String password) =>
_run(() => fb.FirebaseAuth.instance.signInWithEmailAndPassword(
email: email.trim(),
password: password,
));
@override
Future<AuthOutcome> signUpWithEmail(String email, String password) =>
_run(() => fb.FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email.trim(),
password: password,
));
@override
Future<AuthOutcome> sendPasswordReset(String email) async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim());
return const AuthCancelled();
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(_code(error), _message(error));
}
}
Future<AuthOutcome> _run(Future<fb.UserCredential> Function() action) async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
final result = await action();
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(_code(error), _message(error));
}
}
@override
Future<void> signOut() async {
// Firebase 세션 종료는 AuthSession 이 한 번만 담당한다.
}
/// SDK 코드를 에러 카탈로그 ID 로 옮긴다.
///
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
static String _code(fb.FirebaseAuthException error) => switch (error.code) {
'email-already-in-use' => 'E-AUTH-04',
'weak-password' => 'E-AUTH-05',
'invalid-email' => 'E-AUTH-06',
'too-many-requests' => 'E-AUTH-07',
_ => 'E-AUTH-01',
};
static String _message(fb.FirebaseAuthException error) => switch (error.code) {
'email-already-in-use' => '이미 가입된 이메일입니다.',
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.',
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
_ => '이메일 또는 비밀번호를 확인해 주세요.',
};
}
/// LINE — 앱 키가 없어 아직 붙이지 못했다.
///
/// 네이버 자리를 대신한다. 정책 §P-01 이 확정한 5종에 LINE 이 있고 네이버는 없다.
const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [
'LINE Login 채널 ID / 채널 시크릿',
'LINE Developers 콘솔 채널 등록(사업자 정보 필요)',
]);
+242
View File
@@ -0,0 +1,242 @@
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/auth/login_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 이메일 로그인 — 정책 설계서 §P-01 확정 가입 수단.
///
/// 초안 당시 CD-18 미확정이라 비워 두었던 자리이며, 답변서 v1.1 Q7 이
/// **이메일 · Google · Apple · Kakao · LINE 5종**으로 확정했다.
/// 이메일 제공자 대역. Firebase 없이 결과만 돌려준다.
class _FakeEmail implements EmailAuthProvider {
_FakeEmail({this.result = const AuthCancelled()});
AuthOutcome result;
String? lastEmail;
String? lastPassword;
bool lastWasSignUp = false;
String? resetSentTo;
@override
AuthProviderKind get provider => AuthProviderKind.email;
@override
bool get isLive => true;
@override
List<String> get missingConfig => const [];
@override
Future<AuthOutcome> signIn() async => const AuthCancelled();
@override
Future<AuthOutcome> signInWithEmail(String email, String password) async {
lastEmail = email;
lastPassword = password;
lastWasSignUp = false;
return result;
}
@override
Future<AuthOutcome> signUpWithEmail(String email, String password) async {
lastEmail = email;
lastPassword = password;
lastWasSignUp = true;
return result;
}
@override
Future<AuthOutcome> sendPasswordReset(String email) async {
resetSentTo = email;
return const AuthCancelled();
}
@override
Future<void> signOut() async {}
}
const _account = AuthAccount(
uid: 'u1',
provider: AuthProviderKind.email,
email: 'fan@fanuzu.co.kr',
);
/// 화면 밖에 있으면 먼저 굴려 놓고 누른다. 로그인 화면은 세로로 길다.
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
}
void main() {
group('확정 가입 수단 5종', () {
test('네이버가 빠지고 이메일·LINE 이 들어왔다', () {
final kinds = AuthRepository().providers.map((p) => p.provider).toList();
expect(kinds, hasLength(5));
expect(kinds, contains(AuthProviderKind.email));
expect(kinds, contains(AuthProviderKind.line));
expect(kinds.map((k) => k.name), isNot(contains('naver')));
});
test('이메일만 입력 폼을 요구한다', () {
for (final kind in AuthProviderKind.values) {
expect(kind.needsForm, kind == AuthProviderKind.email);
}
});
test('저장소가 이메일 제공자를 찾아낸다', () {
expect(AuthRepository().emailProvider, isNotNull);
});
});
group('저장소 — 이메일', () {
test('로그인에 성공하면 세션이 바뀐다', () async {
final fake = _FakeEmail(result: const AuthSucceeded(_account));
final auth = AuthRepository(providers: [fake]);
final outcome = await auth.signInWithEmail(
email: 'fan@fanuzu.co.kr',
password: 'password12',
);
expect(outcome, isA<AuthSucceeded>());
expect(auth.isSignedIn, isTrue);
expect(fake.lastWasSignUp, isFalse);
});
test('가입은 로그인과 다른 경로로 간다', () async {
// 한 버튼에 합치면 오타로 친 이메일이 새 계정을 만들어 버린다.
final fake = _FakeEmail(result: const AuthSucceeded(_account));
final auth = AuthRepository(providers: [fake]);
await auth.signInWithEmail(
email: 'fan@fanuzu.co.kr',
password: 'password12',
signUp: true,
);
expect(fake.lastWasSignUp, isTrue);
});
test('실패하면 세션이 그대로다', () async {
final auth = AuthRepository(
providers: [_FakeEmail(result: const AuthFailed('E-AUTH-01', '확인해 주세요.'))],
);
final outcome = await auth.signInWithEmail(email: 'a@b.c', password: 'x');
expect(outcome, isA<AuthFailed>());
expect(auth.isSignedIn, isFalse);
});
test('이메일 제공자가 없으면 실패로 닫는다', () async {
final auth = AuthRepository(providers: const []);
final outcome = await auth.signInWithEmail(email: 'a@b.c', password: 'x');
expect(outcome, isA<AuthFailed>());
});
});
group('로그인 화면 — 이메일 폼', () {
Future<AuthRepository> pump(WidgetTester tester, _FakeEmail fake) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
final auth = AuthRepository(providers: [fake]);
await tester.pumpWidget(MaterialApp(home: LoginScreen(auth: auth)));
return auth;
}
testWidgets('처음에는 폼이 접혀 있고 버튼을 누르면 펼쳐진다', (tester) async {
await pump(tester, _FakeEmail());
expect(find.text('이메일'), findsNothing);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
expect(find.text('이메일'), findsOneWidget);
expect(find.text('비밀번호'), findsOneWidget);
});
testWidgets('빈 값으로 제출하면 안내가 뜨고 호출하지 않는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
await tester.pump();
expect(find.text('이메일을 입력해 주세요.'), findsOneWidget);
expect(fake.lastEmail, isNull);
});
testWidgets('이메일 형식이 아니면 막는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr');
await tester.enterText(find.byType(TextFormField).last, 'password12');
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
await tester.pump();
expect(find.text('이메일 형식이 올바르지 않습니다.'), findsOneWidget);
expect(fake.lastEmail, isNull);
});
testWidgets('가입 모드에서는 짧은 비밀번호를 막는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await tapVisible(tester, find.text('계정 만들기'));
await tester.pump();
await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr');
await tester.enterText(find.byType(TextFormField).last, 'short');
await tapVisible(tester, find.widgetWithText(FilledButton, '가입하기'));
await tester.pump();
expect(
find.text('${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.'),
findsOneWidget,
);
expect(fake.lastEmail, isNull);
});
testWidgets('올바른 값이면 제공자를 부른다', (tester) async {
final fake = _FakeEmail(result: const AuthSucceeded(_account));
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr');
await tester.enterText(find.byType(TextFormField).last, 'password12');
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
await tester.pump();
await tester.pump();
expect(fake.lastEmail, 'fan@fanuzu.co.kr');
expect(fake.lastPassword, 'password12');
});
testWidgets('재설정은 이메일이 비어 있으면 보내지 않는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await tapVisible(tester, find.text('비밀번호 재설정'));
await tester.pump();
expect(fake.resetSentTo, isNull);
expect(find.textContaining('먼저 입력해'), findsOneWidget);
});
testWidgets('재설정 안내는 계정 존재 여부를 드러내지 않는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await tester.enterText(find.byType(TextFormField).first, 'nobody@fanuzu.co.kr');
await tapVisible(tester, find.text('비밀번호 재설정'));
await tester.pump();
await tester.pump();
expect(fake.resetSentTo, 'nobody@fanuzu.co.kr');
// 「가입되지 않은 이메일」이라고 알려 주면 가입 목록을 훑을 수 있다.
expect(find.textContaining('보냈습니다'), findsOneWidget);
});
});
}