가입 수단 5종(이메일·Google·Apple·Kakao·LINE) · 로그인 에뮬레이터 연결 · 최소 지원 Android 10
This commit is contained in:
@@ -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 이어야 한다
|
||||||
|
```
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 = '테스트 지정'}) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 자체는 어댑터 뒤에 둔다. 덕분에
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +22,77 @@ 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;
|
||||||
|
|
||||||
|
/// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다.
|
||||||
|
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);
|
||||||
|
final outcome = await widget.auth.signInWithEmail(
|
||||||
|
email: _email.text,
|
||||||
|
password: _password.text,
|
||||||
|
signUp: _signUp,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_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,
|
||||||
|
AuthCancelled() => null,
|
||||||
|
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||||
|
AuthNotConfigured(:final provider, :final missing) =>
|
||||||
|
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
||||||
|
'필요한 항목: ${missing.join(' · ')}',
|
||||||
|
};
|
||||||
|
|
||||||
Future<void> _signIn(SocialProvider provider) async {
|
Future<void> _signIn(SocialProvider provider) async {
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
final outcome = await widget.auth.signIn(provider);
|
final outcome = await widget.auth.signIn(provider);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
_notice = switch (outcome) {
|
_notice = _noticeOf(outcome);
|
||||||
AuthSucceeded() => null,
|
|
||||||
AuthCancelled() => null,
|
|
||||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
|
||||||
AuthNotConfigured(:final provider, :final missing) =>
|
|
||||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
|
||||||
'필요한 항목: ${missing.join(' · ')}',
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 콘솔 채널 등록(사업자 정보 필요)',
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user