로그인 4종(Google·Apple·Kakao·LINE) 전환 · 이메일 가입 비노출 · 계정 연결 · STATION 탭 비노출 플래그

This commit is contained in:
Sn-Kinos
2026-09-11 02:56:41 +09:00
parent 4f2974b88c
commit d9b6b7c7a0
10 changed files with 910 additions and 259 deletions
+47 -10
View File
@@ -7,7 +7,7 @@
| | 내용 |
|---|---|
| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 5탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 4종 소셜 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · Firebase 초기화(미설정 시 목 모드) |
| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) |
| **미포함** | 각 탭의 실제 화면(3주차 이후) · 온보딩 나머지 6화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS) · 도메인 로직(POP·FPI·퀘스트·이클립스) · 상태관리 라이브러리 |
미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).**
@@ -113,8 +113,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
| ID | 항목 | 투입 위치 | 없으면 |
|---|---|---|---|
| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 미투입」 안내를 반환 |
| Q3 | 네이버 client ID / secret | 미정 | 네이버 버튼이 「키 미투입」 안내를 반환 |
| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 대기」 안내 다이얼로그를 띄운다 |
| Q3 | LINE Login 채널 ID / 시크릿 | 미정 | LINE 버튼이 「키 대기」 안내 다이얼로그를 띄운다 |
| Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 |
| ~~Q14~~ | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | **2026-09-11 확정 — 별첨 C 4번 `kr.co.fanuzu.app`.** Android applicationId·namespace·Kotlin 패키지, iOS bundle id 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** |
| ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 |
@@ -133,16 +133,52 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
미투입 상태에서는 **시스템 폰트로 폴백**된다. 크기·굵기·행간·자간은 토큰 값 그대로 적용되므로
투입 전후 레이아웃 차이는 자소 폭 수준에 그친다.
## 6. 소셜 로그인 상태
## 6. 로그인 — 별첨 B v1.4 확정 4종
화면명세 `S-A-LOGIN.md` 와 시안이 4종(카카오·네이버·Apple·Google)이고, 버튼 순서·라벨을 그대로 옮겼다.
> 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 X 로그인은
> 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.
> — 별첨B_정책설계서_요구사항정의서_반영_회신표_v1.4
버튼 순서는 이 회신 문구 순서다(`AuthRepository._defaultProviders()`).
| 제공자 | 상태 | 구현 | 막고 있는 것 |
|---|---|---|---|
| 카카오 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
| 네이버 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability + Android Service ID/redirect URI |
| Google | 실 코드 경로 | `GoogleSignInProvider` | Firebase 설정(S2) |
| Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 |
| Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI |
| 카카오 | 키 대기 | `KeylessSocialProvider` | 카카오 네이티브 앱 키(Q3) |
| LINE | 키 대기 | `KeylessSocialProvider` | LINE Login 채널 ID/시크릿(Q3) |
키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지
**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다.
### 이메일 가입 — 사용자 노출 없음
별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 로그인 화면에 이메일 버튼도,
「회원가입」도, 「비밀번호 재설정」도 없다. 닫은 방법은 **기능 플래그가 아니라 목록에서 빼는 것**이다 —
`AuthRepository.providers` 에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면 닿는 경로가 없다.
발주사 QA 계정(`qa-test@fanuzu.co.kr`)용 이메일 로그인 코드는 남아 있으나 입구가 하나뿐이다:
* **디버그 빌드에서 로그인 화면의 FANUZU 로고를 길게 누른다.**
* 릴리스 빌드에서는 `AuthRepository.qaEmailProvider` 가 `null` 이라 폼이 열리지 않고
`signInWithEmail` 도 실패로 닫힌다. 회귀 가드 4건이 이걸 고정한다.
* 보이는 「QA 로그인」 버튼을 두지 않은 이유: **디버그 APK 도 발주사가 설치해 보는 산출물**이라
그 버튼 자체가 「이메일 가입이 살아 있다」로 읽힌다.
### 계정 연결 — 하나의 UID 에 복수 Provider
`FirebaseAccountLinker` 가 `User.linkWithCredential` / `User.unlink` 를 쓴다.
`signInWithCredential` 이 아니라 **링크 API 만** 쓰는 것이 「UID 는 하나」를 지키는 지점이다 —
전자는 해당 계정이 없으면 새로 만들어 버린다.
* 로그인과 연결은 자격증명을 얻는 데까지 완전히 같아서 그 공통 부분을 `CredentialProvider`
하나로 뺐다. 두 벌로 적으면 Apple nonce 같은 보안 절차가 한쪽에서만 고쳐진다.
* 명세 `554:22797`(*동일 이메일만 연동 가능*)은 **붙이기 전에** 판정한다. 붙인 뒤 되돌리는
방식은 `unlink` 가 실패하면 남의 계정이 붙은 채로 남는다.
단 Apple 은 **최초 인가 때만** 이메일을 주므로 재연동 시에는 판정할 값이 없어 통과한다 —
서버에서 다시 보려면 Functions 층(fn-account-link)에 같은 판정을 둔다.
* **마지막 하나 남은 Provider 는 뗄 수 없다.** 떼는 순간 로그인 수단이 0개가 되어 계정에
다시 들어올 방법이 사라지는데, Firebase 는 이걸 막아 주지 않는다.
설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고
`AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다.
@@ -290,7 +326,8 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail
|---|---|---|---|
| L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) |
| L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 |
| L3 | 카카오·네이버 SDK 미도입 | 스텁만 있다. 실 연동 시 `kakao_flutter_sdk_user` · 네이버 SDK 추가와 함께 `KeylessSocialProvider` 를 실 구현체로 교체 | Q3 회신 후 |
| L3 | 카카오·LINE SDK 미도입 | 「키 대기」 배지 + 안내 다이얼로그까지만 있다. 키가 오면 `kakao_flutter_sdk_user` · LINE SDK 를 넣고 `KeylessSocialProvider` 를 `CredentialProvider` 구현체로 교체하면 로그인·계정 연결이 함께 열린다. Firebase 쪽은 OIDC 커스텀 제공자(`oidc.kakao` · `oidc.line`)로 등록한다 | Q3 회신 후 |
| L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 |
| L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 |
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
| L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 |
+56
View File
@@ -0,0 +1,56 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// 원격 기능 플래그 — Firestore `config/features` 문서 한 장.
///
/// 별첨 B v1.4 (STATION) — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
/// Route/데이터 구조만 유지해 향후 **앱 재배포 없이** 활성화합니다.*
///
/// 「재배포 없이」가 이 클래스가 존재하는 이유 전부다. 컴파일 상수나
/// `--dart-define` 은 값을 바꾸려면 새 빌드를 올려야 해 회신 문구를 만족하지 못한다.
/// 반대로 `firebase_remote_config` 는 신규 의존성이라 pubspec 머리말대로 최소 지원
/// OS 를 다시 계산해야 하는데, `firestore.rules` 의 `config/{id}` 가 이미
/// `allow read: if true` 로 열려 있고 `cloud_firestore` 는 이미 들어와 있다.
/// 그래서 이미 있는 것으로 끝낸다.
///
/// **기본값은 전부 꺼짐이다.** 문서가 없거나, 목 모드거나, 읽기가 실패해도 STATION
/// 은 나타나지 않는다 — 「원격 값을 못 읽었다」가 「켜졌다」가 되면 안 된다.
class FeatureFlags extends ChangeNotifier {
FeatureFlags({Stream<Map<String, Object?>>? source}) {
if (source != null) _subscription = source.listen(_apply, onError: _ignore);
}
/// Firestore 문서 ID. 서버 설계서에 넘기는 계약 이름이다.
static const String documentPath = 'config/features';
/// STATION 탭 노출 스위치. 문서의 `stationEnabled`(bool) 하나를 읽는다.
static const String stationKey = 'stationEnabled';
StreamSubscription<Map<String, Object?>>? _subscription;
bool _stationTab = false;
/// 하단 탭에 STATION 을 그릴 것인가. 기본 false.
bool get stationTab => _stationTab;
void _apply(Map<String, Object?> data) {
// 타입까지 확인한다. 누가 문자열 "true" 를 넣어도 켜지지 않는다.
final next = data[stationKey] == true;
if (next == _stationTab) return;
_stationTab = next;
notifyListeners();
}
/// 원격 읽기 실패는 꺼짐 상태를 유지하는 것으로 끝낸다. 화면에 띄울 것이 없다.
void _ignore(Object error) {
debugPrint('[FeatureFlags] read failed: ${error.runtimeType}');
}
@override
void dispose() {
unawaited(_subscription?.cancel());
_subscription = null;
super.dispose();
}
}
+86 -17
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../config/feature_flags.dart';
import '../../features/auth/auth_repository.dart';
import '../../features/auth/login_screen.dart';
import '../../features/galaxy/galaxy_screen.dart';
@@ -11,12 +12,23 @@ import '../../features/station/station_screen.dart';
/// 하단 탭 하나.
class AppTab {
const AppTab({required this.label, required this.path, required this.icon});
const AppTab({
required this.label,
required this.path,
required this.icon,
this.gatedBy,
});
/// **계약 고정 문자열.** 한글화·치환·순서 변경 금지.
final String label;
final String path;
final IconData icon;
/// 이 탭을 여는 원격 플래그. null 이면 항상 보인다.
///
/// 플래그가 붙어도 [appTabs]·라우트·저장소는 그대로 남는다 — 별첨 B v1.4 가
/// STATION 에 대해 「Route/데이터 구조만 유지」를 요구했기 때문이다.
final bool Function(FeatureFlags flags)? gatedBy;
}
/// 계약 고정 5탭.
@@ -28,21 +40,42 @@ class AppTab {
///
/// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다.
/// 아이콘은 아직 확정 자산이 없어 Material 기본 아이콘을 쓴다(README 「미투입 항목」).
///
/// **이 목록은 계약이지 화면이 아니다.** 지금 하단 탭에 실제로 그려지는 것은
/// [visibleTabs] 이고 STATION 은 원격 플래그가 켜질 때까지 빠진다 — 목록에서
/// 지우면 `design/craft-allowlist.yaml` 과 어긋나고, 켜려면 재배포가 필요해진다.
const List<AppTab> appTabs = [
AppTab(label: 'HOME', path: '/home', icon: Icons.home_outlined),
AppTab(label: 'INSIDE', path: '/inside', icon: Icons.chat_bubble_outline),
AppTab(label: 'GALAXY', path: '/galaxy', icon: Icons.auto_awesome_outlined),
AppTab(label: 'STATION', path: '/station', icon: Icons.storefront_outlined),
// 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
// Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.*
AppTab(
label: 'STATION',
path: '/station',
icon: Icons.storefront_outlined,
gatedBy: _stationGate,
),
AppTab(label: 'MY', path: '/my', icon: Icons.account_circle_outlined),
];
bool _stationGate(FeatureFlags flags) => flags.stationTab;
/// 지금 하단 탭에 그릴 탭들. 계약 순서는 그대로고 닫힌 탭만 빠진다.
List<AppTab> visibleTabs(FeatureFlags flags) => [
for (final tab in appTabs)
if (tab.gatedBy?.call(flags) ?? true) tab,
];
const String loginPath = '/login';
/// 5탭 셸 라우트 + 온보딩(로그인) 라우트.
///
/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST ·
/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다.
GoRouter buildRouter(AuthRepository auth) => GoRouter(
GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
final featureFlags = flags ?? FeatureFlags();
return GoRouter(
initialLocation: appTabs.first.path,
refreshListenable: auth,
redirect: (context, state) {
@@ -52,58 +85,94 @@ GoRouter buildRouter(AuthRepository auth) => GoRouter(
return null;
},
routes: [
GoRoute(path: loginPath, builder: (_, _) => LoginScreen(auth: auth)),
GoRoute(
path: loginPath,
builder: (_, _) => LoginScreen(auth: auth),
),
StatefulShellRoute.indexedStack(
builder: (_, _, shell) => AppShell(shell: shell),
builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags),
branches: [
StatefulShellBranch(
routes: [
GoRoute(path: appTabs[0].path, builder: (_, _) => const HomeScreen()),
GoRoute(
path: appTabs[0].path,
builder: (_, _) => const HomeScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: appTabs[1].path, builder: (_, _) => const InsideScreen()),
GoRoute(
path: appTabs[1].path,
builder: (_, _) => const InsideScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: appTabs[2].path, builder: (_, _) => const GalaxyScreen()),
GoRoute(
path: appTabs[2].path,
builder: (_, _) => const GalaxyScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: appTabs[3].path, builder: (_, _) => const StationScreen()),
GoRoute(
path: appTabs[3].path,
builder: (_, _) => const StationScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: appTabs[4].path, builder: (_, _) => const MyScreen()),
GoRoute(
path: appTabs[4].path,
builder: (_, _) => const MyScreen(),
),
],
),
],
),
],
);
}
/// 5탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다.
/// 탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다.
///
/// **브랜치는 언제나 [appTabs] 5개 전부다.** 숨긴 탭의 브랜치를 빼면 라우트가
/// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는
/// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다.
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.shell});
const AppShell({super.key, required this.shell, required this.flags});
final StatefulNavigationShell shell;
final FeatureFlags flags;
@override
Widget build(BuildContext context) => Scaffold(
Widget build(BuildContext context) => ListenableBuilder(
listenable: flags,
builder: (context, _) {
final tabs = visibleTabs(flags);
// 보이는 목적지 번호 ↔ 셸 브랜치 번호. 숨긴 탭 때문에 어긋나는 지점이다.
final branches = [for (final tab in tabs) appTabs.indexOf(tab)];
final selected = branches.indexOf(shell.currentIndex);
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
// 숨긴 탭에 머무는 동안(딥링크 등)에는 선택 표시를 첫 탭으로 둔다.
selectedIndex: selected < 0 ? 0 : selected,
onDestinationSelected: (i) => shell.goBranch(
branches[i],
// initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다.
onDestinationSelected: (i) =>
shell.goBranch(i, initialLocation: i == shell.currentIndex),
initialLocation: branches[i] == shell.currentIndex,
),
destinations: [
for (final tab in appTabs)
for (final tab in tabs)
NavigationDestination(icon: Icon(tab.icon), label: tab.label),
],
),
);
},
);
}
+86 -15
View File
@@ -1,19 +1,20 @@
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
///
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** 카카오·네이버는 발주사 사업자 정보로
/// 개발자 콘솔에 앱을 등록해야 키가 나오고(Q3), Google·Apple 은 Firebase 설정 파일이
/// 있어야 한다(S1·S2). 둘 다 "아직 준비가 안 됐다"이지 "지원하지 않는다"가 아니므로
/// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다.
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자
/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지
/// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다.
/// 화면은 이걸 안내 다이얼로그로 보여 준다.
library;
/// 가입 수단 — **정책 설계서 §P-01 확정값 5종**(답변서 v1.1 Q7).
/// 로그인 수단 — **별첨 B v1.4 확정값**.
///
/// 초안 당시 CD-18 이 미확정이라 소셜 4종(카카오·네이버·Apple·Google)만 두었는데,
/// 답변서가 **이메일 · Google · Apple · Kakao · LINE** 으로 확정했다.
/// **네이버는 확정 목록에 없고, 이메일과 LINE 이 새로 들어왔다.**
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
/// > X 로그인은 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.
///
/// 앱 Figma 에는 WeChat · X 버튼도 그려져 있으나 답변서가 5종으로 정했으므로
/// 1차는 5종이다. 추가 시 재작업이 없도록 제공자를 인터페이스 뒤에 둔다.
/// 답변서 v1.1 Q7 은 이메일을 포함한 5종이었으나 별첨 B v1.4 가 이를 뒤집었다.
/// [email] 값이 남아 있는 것은 **QA 계정(qa-test@fanuzu.co.kr) 내부 경로** 하나
/// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지
/// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다.
enum AuthProviderKind {
email('이메일로 시작하기'),
kakao('카카오로 시작하기'),
@@ -26,7 +27,7 @@ enum AuthProviderKind {
/// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다.
final String label;
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. 화면이 이걸로 갈린다.
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다.
bool get needsForm => this == AuthProviderKind.email;
}
@@ -101,11 +102,12 @@ abstract interface class SocialAuthProvider {
Future<void> signOut();
}
/// 이메일 제공자만 쓰는 확장 계약.
/// 이메일 제공자 — **QA 계정 전용 내부 경로**.
///
/// 소셜은 외부 창이 열리고 끝이지만 이메일은 **앱 안에서 값을 받아야 한다.**
/// [SocialAuthProvider.signIn] 하나로 묶으면 화면이 제공자 종류를 보고 분기하게
/// 되므로, 입력이 필요한 쪽만 별도 계약으로 뺀다.
/// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 코드를 지우지 않은 것은
/// 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후 화면을 확인해야 하기
/// 때문이며, [AuthRepository.qaEmailProvider] 가 **디버그 빌드에서만** 이 구현체를
/// 돌려준다. 릴리스 빌드에는 부를 수 있는 경로가 없다.
abstract interface class EmailAuthProvider implements SocialAuthProvider {
Future<AuthOutcome> signInWithEmail(String email, String password);
@@ -126,3 +128,72 @@ abstract interface class AuthSession {
Future<void> signOut();
}
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
///
/// **로컬 파트만 비교한다.** 명세의 예시가 `test123@naver.com` 과
/// `test123@gmail.com` 을 같다고 보므로 도메인은 보지 않는다. 전체 문자열을
/// 비교하면 명세가 (O) 로 적은 사례가 막힌다.
bool sameAccountIdentity(String a, String b) => _localPart(a) == _localPart(b);
String _localPart(String email) => email.trim().toLowerCase().split('@').first;
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
/// 이메일은 QA 내부 경로라 목록에 없다.
const List<AuthProviderKind> linkableProviders = [
AuthProviderKind.google,
AuthProviderKind.apple,
AuthProviderKind.kakao,
AuthProviderKind.line,
];
/// 하나의 FANUZU UID 에 소셜 Provider 를 더 붙이거나 떼는 경계.
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
abstract interface class AccountLinker {
/// 지금 UID 에 붙어 있는 Provider → 그 Provider 가 준 이메일(모르면 null).
///
/// 설정 화면의 「SNS 연동」 목록이 이걸 그대로 그린다.
Map<AuthProviderKind, String?> get linkedAccounts;
Future<AuthOutcome> link(AuthProviderKind provider);
/// 마지막 하나 남은 Provider 는 뗄 수 없다 — 떼면 로그인 수단이 사라진다.
Future<AuthOutcome> unlink(AuthProviderKind provider);
}
/// 설정 파일·키가 없어 실제로는 붙일 수 없는 상태의 연동 경계.
///
/// 목 모드에서도 「SNS 연동」 화면 자체는 열려야 한다(별첨2 확인 항목의 화면 동선).
/// 상태를 메모리에만 들고 있고 UID 에는 아무 일도 하지 않는다.
final class SampleAccountLinker implements AccountLinker {
SampleAccountLinker({Map<AuthProviderKind, String?>? seed})
: _linked = {...?seed};
final Map<AuthProviderKind, String?> _linked;
@override
Map<AuthProviderKind, String?> get linkedAccounts => {..._linked};
@override
Future<AuthOutcome> link(AuthProviderKind provider) async {
_linked[provider] = 'fan@fanuzu.co.kr';
return AuthSucceeded(
AuthAccount(uid: 'mock-local', provider: provider, isMock: true),
);
}
@override
Future<AuthOutcome> unlink(AuthProviderKind provider) async {
if (_linked.length <= 1) {
return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.');
}
_linked.remove(provider);
return AuthSucceeded(
AuthAccount(uid: 'mock-local', provider: provider, isMock: true),
);
}
}
+33 -15
View File
@@ -11,8 +11,10 @@ class AuthRepository extends ChangeNotifier {
AuthRepository({
List<SocialAuthProvider>? providers,
AuthSession? session,
EmailAuthProvider? qaEmailProvider,
bool? releaseMode,
}) : providers = providers ?? _defaultProviders(),
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
_session = session,
_account = session?.currentAccount,
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
@@ -23,21 +25,28 @@ class AuthRepository extends ChangeNotifier {
final bool _releaseMode;
final AuthSession? _session;
final EmailAuthProvider _qaEmail;
StreamSubscription<AuthAccount?>? _sessionSubscription;
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
/// 로그인 화면에 그릴 제공자 목록. **여기 없는 수단은 사용자가 닿을 수 없다.**
final List<SocialAuthProvider> providers;
AuthAccount? _account;
AuthAccount? get account => _account;
bool get isSignedIn => _account != null;
/// 정책 설계서 §P-01 확정 5종. **순서가 화면 버튼 순서다.**
/// **별첨 B v1.4 확정 4종.** 순서가 회신 문구 순서이자 화면 버튼 순서다.
///
/// 네이버가 빠지고 이메일·LINE 이 들어왔다(답변서 Q7). 카카오·LINE 은 개발자
/// 콘솔 앱 등록에 발주사 사업자 정보가 필요해 아직 키가 없다.
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
/// > X 로그인은 제외합니다.
///
/// **이메일은 여기 없다.** 목록에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
///
/// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라
/// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다.
static List<SocialAuthProvider> _defaultProviders() => [
const EmailPasswordProvider(),
const GoogleSignInProvider(),
AppleSignInProvider(
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
@@ -49,21 +58,23 @@ class AuthRepository extends ChangeNotifier {
lineProvider,
];
/// 이메일 제공자. 화면이 입력 폼을 띄우기 전에 있는지 확인한다.
EmailAuthProvider? get emailProvider {
for (final p in providers) {
if (p is EmailAuthProvider) return p;
}
return null;
}
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
///
/// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 그래도 발주사가
/// 로그인 이후 화면을 확인하려면 실 계정 하나로 들어갈 수단이 필요해 코드를
/// 남겼고, 릴리스 빌드에서는 null 이라 부를 수 있는 경로 자체가 없다.
///
/// [isMockSessionAvailable] 과 같은 이유로 빌드 모드로 막는다 — 릴리스
/// 산출물에 내부 우회 입구가 새어 나가면 안 된다.
EmailAuthProvider? get qaEmailProvider => _releaseMode ? null : _qaEmail;
/// 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다.
/// QA 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다.
Future<AuthOutcome> signInWithEmail({
required String email,
required String password,
bool signUp = false,
}) async {
final impl = emailProvider;
final impl = qaEmailProvider;
if (impl == null) {
return const AuthFailed('E-AUTH-03', '이메일 로그인을 쓸 수 없습니다.');
}
@@ -81,7 +92,14 @@ class AuthRepository extends ChangeNotifier {
}
Future<AuthOutcome> signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider);
final impl = providers.firstWhere(
(p) => p.provider == provider,
orElse: () => throw ArgumentError.value(
provider,
'provider',
'노출 목록에 없는 로그인 수단이다 (별첨 B v1.4 확정 4종)',
),
);
late final AuthOutcome outcome;
try {
outcome = await impl.signIn();
@@ -0,0 +1,131 @@
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:flutter/foundation.dart';
import 'auth.dart';
import 'social_providers.dart';
/// 하나의 FANUZU UID 에 소셜 Provider 를 붙이고 떼는 Firebase 어댑터.
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
///
/// **새 계정을 만들지 않는다.** `signInWithCredential` 은 자격증명에 해당하는 계정이
/// 없으면 새로 만들어 버리지만 `linkWithCredential` 은 지금 로그인한 UID 에 붙이기만
/// 한다. 이 구분이 「UID 는 하나」를 지키는 지점이라 연결 경로는 링크 API 만 쓴다.
final class FirebaseAccountLinker implements AccountLinker {
FirebaseAccountLinker({
required List<SocialAuthProvider> providers,
fb.FirebaseAuth? firebaseAuth,
}) : _providers = providers,
_firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance;
final List<SocialAuthProvider> _providers;
final fb.FirebaseAuth _firebaseAuth;
/// Firebase providerId ↔ 앱 제공자. `authStateChanges` 로 오는 값과 같은 표를 쓴다.
static const Map<AuthProviderKind, String> providerIds = {
AuthProviderKind.google: 'google.com',
AuthProviderKind.apple: 'apple.com',
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다.
AuthProviderKind.kakao: 'oidc.kakao',
AuthProviderKind.line: 'oidc.line',
AuthProviderKind.email: 'password',
};
@override
Map<AuthProviderKind, String?> get linkedAccounts {
final data = _firebaseAuth.currentUser?.providerData;
if (data == null) return const {};
final byId = {for (final d in data) d.providerId: d.email};
return {
for (final entry in providerIds.entries)
if (byId.containsKey(entry.value)) entry.key: byId[entry.value],
};
}
@override
Future<AuthOutcome> link(AuthProviderKind provider) async {
final user = _firebaseAuth.currentUser;
if (user == null) {
return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.');
}
final impl = _implOf(provider);
if (impl == null) {
return AuthNotConfigured(provider, const ['연결 가능한 제공자 구현']);
}
final missing = impl.missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
// 키 대기 제공자(카카오·LINE)는 자격증명을 만들 수 없다.
if (impl is! CredentialProvider) {
return AuthNotConfigured(provider, const ['자격증명 구현']);
}
final source = impl as CredentialProvider;
try {
final obtained = await source.obtainCredential();
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
if (obtained == null) return const AuthCancelled();
// 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다.
// 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다.
//
// ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가
// 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서
// 다시 보려면 Functions 층에 같은 판정을 둔다.
final mine = user.email;
final theirs = obtained.email;
if (mine != null &&
theirs != null &&
!sameAccountIdentity(mine, theirs)) {
return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.');
}
final result = await user.linkWithCredential(obtained.credential);
return AuthSucceeded(_accountOf(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(authCatalogCode(error), authMessage(error));
} on Object catch (error) {
// SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다.
debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-03', '계정 연결에 실패했습니다.');
}
}
@override
Future<AuthOutcome> unlink(AuthProviderKind provider) async {
final user = _firebaseAuth.currentUser;
if (user == null) {
return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.');
}
// **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에
// 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다.
if (linkedAccounts.length <= 1) {
return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.');
}
final id = providerIds[provider];
if (id == null) {
return const AuthFailed('E-AUTH-10', '연결되어 있지 않은 계정입니다.');
}
try {
await user.unlink(id);
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
SocialAuthProvider? _implOf(AuthProviderKind provider) {
for (final candidate in _providers) {
if (candidate.provider == provider) return candidate;
}
return null;
}
static AuthAccount _accountOf(fb.User? user, AuthProviderKind provider) =>
AuthAccount(
uid: user?.uid ?? '',
provider: provider,
displayName: user?.displayName,
email: user?.email,
);
}
+93 -30
View File
@@ -5,11 +5,15 @@ import '../../core/theme/tokens.dart';
import 'auth.dart';
import 'auth_repository.dart';
/// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`).
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:818` · `510:86330`).
///
/// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google ·
/// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는
/// 버튼 하나로 외부 창을 연다.
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
/// 같은 회신이 이메일/비밀번호 가입과 X 로그인을 1차 범위에서 뺐으므로 이 화면에
/// 이메일 입력 폼으로 가는 **사용자 경로가 없다.** Figma 로그인 화면군의
/// 「회원가입」·「비밀번호 재설정」 동선도 같은 이유로 그리지 않는다.
///
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_openQaForm]).
///
/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다.
class LoginScreen extends StatefulWidget {
@@ -29,8 +33,8 @@ class _LoginScreenState extends State<LoginScreen> {
String? _notice;
bool _busy = false;
/// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다.
bool _emailOpen = false;
/// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외).
bool _qaFormOpen = false;
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
bool _signUp = false;
@@ -63,7 +67,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.');
return;
}
final impl = widget.auth.emailProvider;
final impl = widget.auth.qaEmailProvider;
if (impl == null) return;
setState(() => _busy = true);
final outcome = await impl.sendPasswordReset(email);
@@ -90,11 +94,45 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider);
if (!mounted) return;
setState(() {
_busy = false;
_notice = _noticeOf(outcome);
});
setState(() => _busy = false);
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
// 사용자는 앱이 멈춘 줄 안다.
if (outcome is AuthNotConfigured) {
await _showNotConfigured(outcome);
return;
}
if (!mounted) return;
setState(() => _notice = _noticeOf(outcome));
}
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
final name = outcome.provider.label.replaceAll('로 시작하기', '');
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text('$name 로그인 준비 중'),
content: Text(
'$name 로그인은 발주사 키가 들어오면 열립니다.\n\n'
'필요한 항목\n· ${outcome.missing.join('\n· ')}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('확인'),
),
],
),
);
}
/// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
///
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null
? null
: () => setState(() => _qaFormOpen = !_qaFormOpen);
@override
Widget build(BuildContext context) {
@@ -110,6 +148,8 @@ class _LoginScreenState extends State<LoginScreen> {
// 시안 로그인(173:1905)은 화면 위쪽 가운데에 핑크 원(로고 자리) 하나만 둔다.
// 로고 원본은 미확정(Q6)이라 원 안에 워드마크를 텍스트로 넣는다.
Center(
child: GestureDetector(
onLongPress: _openQaForm,
child: Container(
width: 120,
height: 120,
@@ -128,36 +168,40 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
),
const SizedBox(height: AppSpace.s6),
Text(
'당신의 팬 활동을\n하나의 행성으로',
style: text.headlineSmall?.copyWith(fontWeight: FontWeight.w500),
style: text.headlineSmall?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpace.s3),
Text(
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
style: text.bodySmall?.copyWith(color: AppColors.onCosmicVariant),
style: text.bodySmall?.copyWith(
color: AppColors.onCosmicVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpace.s8),
Text('계정으로 시작하기', style: text.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(
'사용 중인 계정 하나를 선택해 주세요.',
style: text.bodySmall,
),
Text('사용 중인 계정 하나를 선택해 주세요.', style: text.bodySmall),
const SizedBox(height: AppSpace.s4),
for (final p in widget.auth.providers) ...[
_ProviderButton(
provider: p.provider,
// 발주사 키가 아직 없는 제공자. 눌러도 안내 다이얼로그로 닫힌다.
awaitingKeys: p.missingConfig.isNotEmpty,
enabled: !_busy,
onPressed: p.provider.needsForm
? () => setState(() => _emailOpen = !_emailOpen)
: () => _signIn(p.provider),
onPressed: () => _signIn(p.provider),
),
if (p.provider.needsForm && _emailOpen) ...[
const SizedBox(height: AppSpace.s3),
],
if (_qaFormOpen) ...[
const SizedBox(height: AppSpace.s2),
_EmailForm(
formKey: _form,
email: _email,
@@ -168,7 +212,6 @@ class _LoginScreenState extends State<LoginScreen> {
onToggleMode: () => setState(() => _signUp = !_signUp),
onReset: _resetPassword,
),
],
const SizedBox(height: AppSpace.s3),
],
if (_notice != null) ...[
@@ -194,11 +237,17 @@ class _LoginScreenState extends State<LoginScreen> {
class _ProviderButton extends StatelessWidget {
const _ProviderButton({
required this.provider,
required this.awaitingKeys,
required this.enabled,
required this.onPressed,
});
final SocialProvider provider;
/// 「키 대기」 배지를 붙일지. **버튼을 비활성으로 만들지 않는다** — 눌러야
/// 무엇이 없어서 못 쓰는지 안내가 뜬다. 회색 버튼은 사유를 말해 주지 않는다.
final bool awaitingKeys;
final bool enabled;
final VoidCallback onPressed;
@@ -227,7 +276,23 @@ class _ProviderButton extends StatelessWidget {
disabledBackgroundColor: fill.withValues(alpha: 0.4),
side: stroke == null ? null : BorderSide(color: stroke),
),
child: Text(provider.label),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(provider.label, overflow: TextOverflow.ellipsis),
),
if (awaitingKeys) ...[
const SizedBox(width: AppSpace.s2),
Text(
keyPendingBadge,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: label.withValues(alpha: 0.7),
),
),
],
],
),
);
}
}
@@ -265,17 +330,13 @@ class _MockBanner extends StatelessWidget {
const SizedBox(height: AppSpace.s2),
Text(FirebaseBoot.reason, style: text.bodySmall),
const SizedBox(height: AppSpace.s3),
OutlinedButton(
onPressed: onEnter,
child: const Text('목 세션으로 탭 둘러보기'),
),
OutlinedButton(onPressed: onEnter, child: const Text('목 세션으로 탭 둘러보기')),
],
);
}
}
/// 이메일 입력 폼.
/// QA 계정 전용 이메일 입력 폼 — 숨은 입구로만 열린다.
///
/// Figma 「로그인 입력 영역」 명세 —
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
@@ -301,7 +362,6 @@ class _EmailForm extends StatelessWidget {
final VoidCallback onToggleMode;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
@@ -378,6 +438,9 @@ class _EmailForm extends StatelessWidget {
}
}
/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지.
/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다.
const String keyPendingBadge = '키 대기';
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
abstract final class EmailFormMetrics {
+119 -56
View File
@@ -9,11 +9,30 @@ import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
import '../../core/firebase/firebase_boot.dart';
import 'auth.dart';
/// Firebase 자격증명을 만들 수 있는 제공자.
///
/// 로그인과 **계정 연결**(별첨 B v1.4 「복수 소셜 Provider 연결 허용」)은 자격증명을
/// 얻는 데까지 완전히 같고 그 뒤 `signInWithCredential` 이냐 `linkWithCredential`
/// 이냐만 다르다. 그 공통 부분을 여기로 뺀다 — 두 벌로 적으면 nonce 같은 보안
/// 절차가 한쪽에서만 고쳐진다.
abstract interface class CredentialProvider {
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
///
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
Future<SocialCredential?> obtainCredential();
}
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
/// Google 로그인 — 실 코드 경로.
///
/// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다.
/// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다.
final class GoogleSignInProvider implements SocialAuthProvider {
final class GoogleSignInProvider
implements SocialAuthProvider, CredentialProvider {
const GoogleSignInProvider();
static Future<void>? _initialization;
@@ -31,22 +50,31 @@ final class GoogleSignInProvider implements SocialAuthProvider {
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
@override
Future<AuthOutcome> signIn() async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
Future<SocialCredential?> obtainCredential() async {
final signIn = gsi.GoogleSignIn.instance;
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
await (_initialization ??= signIn.initialize());
final account = await signIn.authenticate();
final idToken = account.authentication.idToken;
if (idToken == null) {
if (idToken == null) return null;
return (
credential: fb.GoogleAuthProvider.credential(idToken: idToken),
email: account.email,
);
}
@override
Future<AuthOutcome> signIn() async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
final obtained = await obtainCredential();
if (obtained == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
final result = await fb.FirebaseAuth.instance.signInWithCredential(
credential,
obtained.credential,
);
return AuthSucceeded(_toAccount(result.user, provider));
} on gsi.GoogleSignInException catch (error) {
@@ -55,7 +83,7 @@ final class GoogleSignInProvider implements SocialAuthProvider {
}
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -73,7 +101,8 @@ final class GoogleSignInProvider implements SocialAuthProvider {
/// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
final class AppleSignInProvider implements SocialAuthProvider {
final class AppleSignInProvider
implements SocialAuthProvider, CredentialProvider {
const AppleSignInProvider({this.webAuthenticationOptions});
/// Android·Web에서 필요한 Service ID와 검증된 callback URL.
@@ -94,15 +123,10 @@ final class AppleSignInProvider implements SocialAuthProvider {
if (!FirebaseBoot.isLive) 'Apple 개발자 설정',
];
/// 시도마다 새 nonce 를 만들고 SHA-256 해시를 Apple 에, 원문을 Firebase 에 준다.
/// 재사용하면 탈취한 토큰을 되쏘는 replay 가 성립한다.
@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 {
Future<SocialCredential?> obtainCredential() async {
final rawNonce = apple.generateNonce();
final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
final credential = await apple.SignInWithApple.getAppleIDCredential(
@@ -116,15 +140,35 @@ final class AppleSignInProvider implements SocialAuthProvider {
nonce: hashedNonce,
);
final idToken = credential.identityToken;
if (idToken == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final oauth = fb.OAuthProvider('apple.com').credential(
if (idToken == null) return null;
return (
credential: fb.OAuthProvider('apple.com').credential(
idToken: idToken,
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
),
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
email: credential.email,
);
}
@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 obtained = await obtainCredential();
if (obtained == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final result = await fb.FirebaseAuth.instance.signInWithCredential(
obtained.credential,
);
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) {
@@ -135,7 +179,7 @@ final class AppleSignInProvider implements SocialAuthProvider {
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -148,10 +192,11 @@ final class AppleSignInProvider implements SocialAuthProvider {
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·LINE.
/// 발주사 키를 기다리는 제공자 — 카카오·LINE.
///
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다.
/// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다.
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려주고
/// 로그인 화면이 그걸 안내 다이얼로그로 띄운다. 키가 들어오면 이 자리에 실 구현체를
/// 넣고 [AuthRepository] 목록만 바꾸면 된다 — 화면·라우터는 그대로다.
final class KeylessSocialProvider implements SocialAuthProvider {
const KeylessSocialProvider(this.provider, this.missingConfig);
@@ -198,14 +243,18 @@ AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
email: user?.email,
);
/// 이메일 · 비밀번호 — 실 코드 경로.
/// 이메일 · 비밀번호 — **QA 계정 전용 내부 경로**.
///
/// 정책 설계서 §P-01 이 가입 수단 5종에 이메일을 넣어 확정했다(답변서 Q7).
/// 초안 당시 CD-18 미확정이라 비워 두었던 자리다.
/// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고
/// 이메일/비밀번호 가입을 제외했다. 그래서 이 구현체는
/// [AuthRepository.providers] 에 들어가지 않는다 — 사용자가 닿을 수 있는 버튼이
/// 없다. 남겨 둔 이유는 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후
/// 화면을 확인해야 하기 때문이고, [AuthRepository.qaEmailProvider] 가
/// **디버그 빌드에서만** 이걸 돌려준다.
///
/// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이
/// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가
/// 사라졌다」를 겪는다. Figma 도 로그인 화면과 회원가입 화면을 나눠 두었다.
/// 사라졌다」를 겪는다.
final class EmailPasswordProvider implements EmailAuthProvider {
const EmailPasswordProvider();
@@ -250,7 +299,7 @@ final class EmailPasswordProvider implements EmailAuthProvider {
await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim());
return const AuthCancelled();
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(_code(error), _message(error));
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -261,7 +310,7 @@ final class EmailPasswordProvider implements EmailAuthProvider {
final result = await action();
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(_code(error), _message(error));
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -269,32 +318,46 @@ final class EmailPasswordProvider implements EmailAuthProvider {
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 — 앱 키가 없어 아직 붙이지 못했다.
/// LINE — 발주사 키 대기.
///
/// 네이버 자리를 대신한다. 정책 §P-01 이 확정한 5종에 LINE 이 있고 네이버는 없다.
/// 별첨 B v1.4 확정 4종에 LINE 이 있고 네이버·X 는 없다.
const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [
'LINE Login 채널 ID / 채널 시크릿',
'LINE Developers 콘솔 채널 등록(사업자 정보 필요)',
]);
/// SDK 코드를 에러 카탈로그(`wireframe/planning/error-catalog.yaml`) ID 로 옮긴다.
///
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
String authCatalogCode(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',
// 계정 연결(별첨 B v1.4) 경로에서만 나오는 코드들.
'credential-already-in-use' ||
'account-exists-with-different-credential' => 'E-AUTH-08',
'provider-already-linked' => 'E-AUTH-09',
'no-such-provider' => 'E-AUTH-10',
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
'operation-not-allowed' => 'E-AUTH-11',
'requires-recent-login' => 'E-AUTH-12',
_ => 'E-AUTH-01',
};
String authMessage(fb.FirebaseAuthException error) => switch (error.code) {
'email-already-in-use' => '이미 가입된 이메일입니다.',
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.',
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
'credential-already-in-use' || 'account-exists-with-different-credential' =>
'다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
'provider-already-linked' => '이미 연결된 계정입니다.',
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
_ => '이메일 또는 비밀번호를 확인해 주세요.',
};
+106 -7
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:fancake/app.dart';
import 'package:fancake/core/config/feature_flags.dart';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/features/auth/auth.dart';
@@ -23,12 +25,60 @@ void main() {
expect(tester.takeException(), isNull);
expect(find.text('FANUZU'), findsOneWidget);
// 시안 S-A-LOGIN 의 소셜 버튼 4종이 라벨 그대로 있다.
for (final provider in SocialProvider.values) {
expect(find.text(provider.label), findsOneWidget, reason: provider.name);
// 별첨 B v1.4 확정 4종이 라벨 그대로 있다.
for (final provider in AuthRepository().providers) {
expect(
find.text(provider.provider.label),
findsOneWidget,
reason: provider.provider.name,
);
}
});
// 별첨 B v1.4 — *이메일/비밀번호 가입 및 X 로그인은 제외합니다.*
// 발주사가 「질의 없이 임의 변경」을 지적한 뒤의 회신이라 회귀 가드를 건다.
group('이메일 가입은 사용자에게 노출되지 않는다', () {
test('노출 제공자 목록은 Google·Apple·Kakao·LINE 4종뿐이다', () {
final kinds = AuthRepository().providers.map((p) => p.provider).toList();
expect(kinds, const [
AuthProviderKind.google,
AuthProviderKind.apple,
AuthProviderKind.kakao,
AuthProviderKind.line,
]);
expect(kinds, isNot(contains(AuthProviderKind.email)));
});
testWidgets('로그인 화면에 이메일 버튼·가입·재설정 입구가 없다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
await tester.pumpAndSettle();
expect(find.text(AuthProviderKind.email.label), findsNothing);
expect(find.text('이메일'), findsNothing);
expect(find.text('비밀번호'), findsNothing);
expect(find.text('계정 만들기'), findsNothing);
expect(find.text('비밀번호 재설정'), findsNothing);
});
test('릴리스 빌드에는 QA 이메일 경로가 아예 없다', () async {
final auth = AuthRepository(releaseMode: true);
expect(auth.qaEmailProvider, isNull);
final outcome = await auth.signInWithEmail(
email: 'qa-test@fanuzu.co.kr',
password: 'password12',
);
expect(outcome, isA<AuthFailed>());
expect(auth.isSignedIn, isFalse);
});
test('디버그 빌드에서는 QA 이메일 경로가 살아 있다', () {
expect(AuthRepository(releaseMode: false).qaEmailProvider, isNotNull);
});
});
testWidgets('저장된 세션이 있으면 로그인 화면 대신 5탭 셸로 복원된다', (tester) async {
final session = _SmokeSession(
const AuthAccount(uid: 'restored-user', provider: SocialProvider.google),
@@ -56,9 +106,58 @@ void main() {
.map((d) => d.label)
.toList();
// 하드코딩이 아니라 계약 원본(design/craft-allowlist.yaml)과 대조한다.
expect(rendered, _contractTabs());
expect(rendered, appTabs.map((t) => t.label).toList());
// 별첨 B v1.4 — STATION 은 사용자 탭에서 비노출. 계약(5탭)은 그대로 두고
// 화면에 그리는 목록만 줄인다.
expect(rendered, ['HOME', 'INSIDE', 'GALAXY', 'MY']);
expect(rendered, visibleTabs(FeatureFlags()).map((t) => t.label).toList());
expect(find.text('STATION'), findsNothing);
});
// 계약(견적서 v2.1 · craft-allowlist.yaml · 시안 탭바)은 5탭 그대로다.
// 화면에서 뺀 것과 계약에서 뺀 것은 다른 일이고, 뒤엣것은 하지 않았다.
test('2-1. 계약 고정 5탭 표기·순서는 그대로다', () {
expect(appTabs.map((t) => t.label).toList(), _contractTabs());
});
group('STATION 은 원격 플래그로 켠다 (별첨 B v1.4 · 재배포 없이)', () {
test('플래그 기본값은 꺼짐이다', () {
expect(FeatureFlags().stationTab, isFalse);
expect(visibleTabs(FeatureFlags()), hasLength(4));
});
test('config/features 의 stationEnabled 가 true 면 5탭이 된다', () async {
final source = StreamController<Map<String, Object?>>();
final flags = FeatureFlags(source: source.stream);
addTearDown(flags.dispose);
source.add({FeatureFlags.stationKey: true});
await Future<void>.delayed(Duration.zero);
expect(flags.stationTab, isTrue);
expect(
visibleTabs(flags).map((t) => t.label).toList(),
appTabs.map((t) => t.label).toList(),
);
await source.close();
});
test('문자열 "true" 나 읽기 실패로는 켜지지 않는다', () async {
final source = StreamController<Map<String, Object?>>();
final flags = FeatureFlags(source: source.stream);
addTearDown(flags.dispose);
source.add({FeatureFlags.stationKey: 'true'});
source.addError(StateError('permission-denied'));
await Future<void>.delayed(Duration.zero);
expect(flags.stationTab, isFalse);
await source.close();
});
test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () {
final station = appTabs.firstWhere((t) => t.label == 'STATION');
expect(station.path, '/station');
});
});
// A-01 회귀 가드 — 목 우회 입구가 배포 앱에 새어 나가지 않는지.
@@ -143,7 +242,7 @@ void main() {
'HOME': ('S-H-HOME', '오늘의 퀘스트'),
'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'),
'GALAXY': ('S-G-PLANET', '내 행성'),
'STATION': ('S-S-HUB', 'STATION 허브'),
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
'MY': ('S-M-HOME', '마이 페이지'),
};
+80 -36
View File
@@ -4,10 +4,13 @@ import 'package:fancake/features/auth/login_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 이메일 로그인 — 정책 설계서 §P-01 확정 가입 수단.
/// 이메일 로그인 — **QA 계정 전용 내부 경로**.
///
/// 초안 당시 CD-18 미확정이라 비워 두었던 자리이며, 답변서 v1.1 Q7 이
/// **이메일 · Google · Apple · Kakao · LINE 5종**으로 확정했다.
/// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고
/// 이메일/비밀번호 가입을 제외했다. 그래서 여기서 지키는 것은 두 가지다:
/// ① 사용자에게 노출되는 목록에 이메일이 없을 것
/// ② 그럼에도 발주사 QA 계정(qa-test@fanuzu.co.kr)으로는 들어갈 수 있을 것 —
/// 단 디버그 빌드에서 숨은 입구(로고 길게 누르기)로만.
/// 이메일 제공자 대역. Firebase 없이 결과만 돌려준다.
class _FakeEmail implements EmailAuthProvider {
@@ -71,13 +74,21 @@ Future<void> tapVisible(WidgetTester tester, Finder finder) async {
}
void main() {
group('확정 가입 수단 5종', () {
test('네이버가 빠지고 이메일·LINE 이 들어왔다', () {
group('별첨 B v1.4 확정 4종', () {
test('노출 목록은 Google·Apple·Kakao·LINE 이고 이메일·네이버·X 는 없다', () {
final kinds = AuthRepository().providers.map((p) => p.provider).toList();
expect(kinds, hasLength(5));
expect(kinds, contains(AuthProviderKind.email));
expect(kinds, hasLength(4));
expect(kinds, isNot(contains(AuthProviderKind.email)));
expect(kinds, contains(AuthProviderKind.line));
expect(kinds.map((k) => k.name), isNot(contains('naver')));
expect(kinds.map((k) => k.name), isNot(contains('x')));
});
test('노출 목록에 없는 수단으로는 signIn 을 부를 수 없다', () {
expect(
() => AuthRepository().signIn(AuthProviderKind.email),
throwsArgumentError,
);
});
test('이메일만 입력 폼을 요구한다', () {
@@ -86,15 +97,16 @@ void main() {
}
});
test('저장소가 이메일 제공자를 찾아낸다', () {
expect(AuthRepository().emailProvider, isNotNull);
test('QA 이메일 경로는 디버그 빌드에만 있다', () {
expect(AuthRepository(releaseMode: false).qaEmailProvider, isNotNull);
expect(AuthRepository(releaseMode: true).qaEmailProvider, isNull);
});
});
group('저장소 — 이메일', () {
group('저장소 — QA 이메일', () {
test('로그인에 성공하면 세션이 바뀐다', () async {
final fake = _FakeEmail(result: const AuthSucceeded(_account));
final auth = AuthRepository(providers: [fake]);
final auth = AuthRepository(qaEmailProvider: fake, releaseMode: false);
final outcome = await auth.signInWithEmail(
email: 'fan@fanuzu.co.kr',
password: 'password12',
@@ -107,7 +119,7 @@ void main() {
test('가입은 로그인과 다른 경로로 간다', () async {
// 한 버튼에 합치면 오타로 친 이메일이 새 계정을 만들어 버린다.
final fake = _FakeEmail(result: const AuthSucceeded(_account));
final auth = AuthRepository(providers: [fake]);
final auth = AuthRepository(qaEmailProvider: fake, releaseMode: false);
await auth.signInWithEmail(
email: 'fan@fanuzu.co.kr',
password: 'password12',
@@ -118,45 +130,73 @@ void main() {
test('실패하면 세션이 그대로다', () async {
final auth = AuthRepository(
providers: [_FakeEmail(result: const AuthFailed('E-AUTH-01', '확인해 주세요.'))],
qaEmailProvider: _FakeEmail(
result: const AuthFailed('E-AUTH-01', '확인해 주세요.'),
),
releaseMode: false,
);
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');
test('릴리스 빌드에서는 QA 경로가 실패로 닫힌다', () async {
final auth = AuthRepository(
qaEmailProvider: _FakeEmail(result: const AuthSucceeded(_account)),
releaseMode: true,
);
final outcome = await auth.signInWithEmail(
email: 'qa-test@fanuzu.co.kr',
password: 'password12',
);
expect(outcome, isA<AuthFailed>());
expect(auth.isSignedIn, isFalse);
});
});
group('로그인 화면 — 이메일 폼', () {
group('로그인 화면 — QA 이메일 폼(숨은 입구)', () {
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]);
final auth = AuthRepository(qaEmailProvider: fake, releaseMode: false);
await tester.pumpWidget(MaterialApp(home: LoginScreen(auth: auth)));
return auth;
}
testWidgets('처음에는 폼이 접혀 있고 버튼을 누르면 펼쳐진다', (tester) async {
/// 숨은 입구 — 로고를 길게 누른다. 보이는 진입점은 두지 않는다.
Future<void> openQaForm(WidgetTester tester) async {
await tester.longPress(find.text('FANUZU'));
await tester.pump();
}
testWidgets('보이는 입구가 없고 로고를 길게 눌러야 열린다', (tester) async {
await pump(tester, _FakeEmail());
expect(find.text('이메일'), findsNothing);
expect(find.text(AuthProviderKind.email.label), findsNothing);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await openQaForm(tester);
expect(find.text('이메일'), findsOneWidget);
expect(find.text('비밀번호'), findsOneWidget);
});
testWidgets('릴리스 빌드에서는 로고를 길게 눌러도 열리지 않는다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(home: LoginScreen(auth: AuthRepository(releaseMode: true))),
);
await tester.longPress(find.text('FANUZU'));
await tester.pump();
expect(find.text('이메일'), findsNothing);
expect(find.text('비밀번호'), findsNothing);
});
testWidgets('빈 값으로 제출하면 안내가 뜨고 호출하지 않는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await openQaForm(tester);
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
await tester.pump();
@@ -167,8 +207,7 @@ void main() {
testWidgets('이메일 형식이 아니면 막는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await openQaForm(tester);
await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr');
await tester.enterText(find.byType(TextFormField).last, 'password12');
@@ -181,12 +220,14 @@ void main() {
testWidgets('가입 모드에서는 짧은 비밀번호를 막는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await openQaForm(tester);
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).first,
'fan@fanuzu.co.kr',
);
await tester.enterText(find.byType(TextFormField).last, 'short');
await tapVisible(tester, find.widgetWithText(FilledButton, '가입하기'));
await tester.pump();
@@ -200,10 +241,12 @@ void main() {
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 openQaForm(tester);
await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr');
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();
@@ -215,8 +258,7 @@ void main() {
testWidgets('재설정은 이메일이 비어 있으면 보내지 않는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await openQaForm(tester);
await tapVisible(tester, find.text('비밀번호 재설정'));
await tester.pump();
@@ -227,10 +269,12 @@ void main() {
testWidgets('재설정 안내는 계정 존재 여부를 드러내지 않는다', (tester) async {
final fake = _FakeEmail();
await pump(tester, fake);
await tapVisible(tester, find.text(AuthProviderKind.email.label));
await tester.pump();
await openQaForm(tester);
await tester.enterText(find.byType(TextFormField).first, 'nobody@fanuzu.co.kr');
await tester.enterText(
find.byType(TextFormField).first,
'nobody@fanuzu.co.kr',
);
await tapVisible(tester, find.text('비밀번호 재설정'));
await tester.pump();
await tester.pump();