로그인을 Figma 2단계(로그인 → 간편 로그인)로 · 회원가입 · 약관 동의 · 가입 완료 화면
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 986 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 857 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -16,17 +16,21 @@ library;
|
|||||||
/// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지
|
/// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지
|
||||||
/// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다.
|
/// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다.
|
||||||
enum AuthProviderKind {
|
enum AuthProviderKind {
|
||||||
email('이메일로 시작하기'),
|
email('이메일로 시작하기', '이메일'),
|
||||||
kakao('카카오로 시작하기'),
|
kakao('카카오로 시작하기', '카카오'),
|
||||||
line('LINE으로 시작하기'),
|
line('LINE으로 시작하기', 'LINE'),
|
||||||
apple('Apple로 시작하기'),
|
apple('Apple로 시작하기', 'Apple'),
|
||||||
google('Google로 시작하기');
|
google('Google로 시작하기', 'Google');
|
||||||
|
|
||||||
const AuthProviderKind(this.label);
|
const AuthProviderKind(this.label, this.shortName);
|
||||||
|
|
||||||
/// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다.
|
/// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다.
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
|
/// 제공자 이름만 — 설정 「SNS 연동」·안내 문구용. [label] 에서 「로 시작하기」를
|
||||||
|
/// 떼어 만들면 「LINE으로」가 「LINE으」로 잘린다(인계 결함).
|
||||||
|
final String shortName;
|
||||||
|
|
||||||
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다.
|
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다.
|
||||||
bool get needsForm => this == AuthProviderKind.email;
|
bool get needsForm => this == AuthProviderKind.email;
|
||||||
}
|
}
|
||||||
@@ -131,12 +135,13 @@ abstract interface class AuthSession {
|
|||||||
|
|
||||||
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
|
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
|
||||||
///
|
///
|
||||||
/// **로컬 파트만 비교한다.** 명세의 예시가 `test123@naver.com` 과
|
/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다(fzu-2z0.5).
|
||||||
/// `test123@gmail.com` 을 같다고 보므로 도메인은 보지 않는다. 전체 문자열을
|
/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이
|
||||||
/// 비교하면 명세가 (O) 로 적은 사례가 막힌다.
|
/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false.
|
||||||
bool sameAccountIdentity(String a, String b) => _localPart(a) == _localPart(b);
|
bool sameAccountIdentity(String a, String b) {
|
||||||
|
final x = a.trim().toLowerCase();
|
||||||
String _localPart(String email) => email.trim().toLowerCase().split('@').first;
|
return x.contains('@') && x == b.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
|
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
|
||||||
/// 이메일은 QA 내부 경로라 목록에 없다.
|
/// 이메일은 QA 내부 경로라 목록에 없다.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AuthRepository extends ChangeNotifier {
|
|||||||
AuthAccount? get account => _account;
|
AuthAccount? get account => _account;
|
||||||
bool get isSignedIn => _account != null;
|
bool get isSignedIn => _account != null;
|
||||||
|
|
||||||
/// **별첨 B v1.4 확정 4종.** 순서가 회신 문구 순서이자 화면 버튼 순서다.
|
/// **별첨 B v1.4 확정 4종.** 순서는 회신 문구 순서다(화면 버튼은 Figma 순서로 다시 줄 세운다).
|
||||||
///
|
///
|
||||||
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
|
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
|
||||||
/// > X 로그인은 제외합니다.
|
/// > X 로그인은 제외합니다.
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
final result = await user.linkWithCredential(obtained.credential);
|
final result = await user.linkWithCredential(obtained.credential);
|
||||||
return AuthSucceeded(_accountOf(result.user, provider));
|
return AuthSucceeded(_accountOf(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.link));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error),
|
||||||
|
authMessage(error, flow: AuthFlow.link),
|
||||||
|
);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
// SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다.
|
// SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다.
|
||||||
debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}');
|
debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}');
|
||||||
@@ -110,7 +113,10 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
await user.unlink(id);
|
await user.unlink(id);
|
||||||
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.link));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error),
|
||||||
|
authMessage(error, flow: AuthFlow.link),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,22 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../core/firebase/firebase_boot.dart';
|
import '../../core/firebase/firebase_boot.dart';
|
||||||
import '../../core/theme/tokens.dart';
|
import '../../core/theme/tokens.dart';
|
||||||
|
import '../../core/ui/app_icons.dart';
|
||||||
import 'auth.dart';
|
import 'auth.dart';
|
||||||
import 'auth_repository.dart';
|
import 'auth_repository.dart';
|
||||||
|
import 'signup_screens.dart';
|
||||||
|
|
||||||
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:818` · `510:86330`).
|
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:1905`) → 「간편 로그인」(`173:2183`) 2단계.
|
||||||
|
///
|
||||||
|
/// 첫 화면은 로고 원과 「로그인」 버튼뿐이고, 제공자 선택은 다음 화면([SimpleLoginScreen])이다.
|
||||||
|
/// 「회원가입」은 같은 틀의 가입 화면(`177:9451`)으로 간다.
|
||||||
///
|
///
|
||||||
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
|
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
|
||||||
/// 같은 회신이 이메일/비밀번호 가입과 X 로그인을 1차 범위에서 뺐으므로 이 화면에
|
/// Figma 의 「Fancake로 로그인」(이메일) · 「X(Twitter)로 로그인」은 같은 회신이 1차 범위에서
|
||||||
/// 이메일 입력 폼으로 가는 **사용자 경로가 없다.** Figma 로그인 화면군의
|
/// 뺐으므로 그리지 않고, 비밀번호 재설정 동선도 없다.
|
||||||
/// 「회원가입」·「비밀번호 재설정」 동선도 같은 이유로 그리지 않는다.
|
|
||||||
///
|
///
|
||||||
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
|
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
|
||||||
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_openQaForm]).
|
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_toggleDevPanel]). 목 세션 입구도 같은 곳에 둔다.
|
||||||
///
|
|
||||||
/// 약관 동의(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});
|
||||||
|
|
||||||
@@ -25,6 +27,9 @@ class LoginScreen extends StatefulWidget {
|
|||||||
State<LoginScreen> createState() => _LoginScreenState();
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 로고 원. 숨은 입구를 찾는 테스트가 이 키로 누른다.
|
||||||
|
const loginLogoKey = Key('login-logo');
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final _email = TextEditingController();
|
final _email = TextEditingController();
|
||||||
final _password = TextEditingController();
|
final _password = TextEditingController();
|
||||||
@@ -33,8 +38,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
String? _notice;
|
String? _notice;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
|
||||||
/// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외).
|
/// 개발용 패널(QA 이메일 폼 · 목 세션)이 열렸는가. 숨은 입구로만 열린다.
|
||||||
bool _qaFormOpen = false;
|
bool _devOpen = false;
|
||||||
|
|
||||||
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
|
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
|
||||||
bool _signUp = false;
|
bool _signUp = false;
|
||||||
@@ -57,7 +62,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
_notice = _noticeOf(outcome);
|
_notice = authNotice(outcome);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,21 +81,140 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_busy = false;
|
_busy = false;
|
||||||
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
|
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
|
||||||
_notice = outcome is AuthFailed
|
_notice = outcome is AuthFailed
|
||||||
? _noticeOf(outcome)
|
? authNotice(outcome)
|
||||||
: '입력하신 주소로 재설정 안내를 보냈습니다.';
|
: '입력하신 주소로 재설정 안내를 보냈습니다.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _noticeOf(AuthOutcome outcome) => switch (outcome) {
|
/// 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
|
||||||
AuthSucceeded() => null,
|
///
|
||||||
AuthCancelled() => null,
|
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
|
||||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
|
||||||
AuthNotConfigured(:final provider, :final missing) =>
|
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
|
||||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
VoidCallback? get _toggleDevPanel =>
|
||||||
'필요한 항목: ${missing.join(' · ')}',
|
widget.auth.qaEmailProvider == null && !widget.auth.isMockSessionAvailable
|
||||||
};
|
? null
|
||||||
|
: () => setState(() => _devOpen = !_devOpen);
|
||||||
|
|
||||||
|
void _open({required bool signUp}) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => SimpleLoginScreen(auth: widget.auth, signUp: signUp),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// 시안 173:1905 — 상단 386 영역 가운데에 핑크 원(120) 하나. 로고 원본 미확정(Q6).
|
||||||
|
SizedBox(
|
||||||
|
height: 386,
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
key: loginLogoKey,
|
||||||
|
onLongPress: _toggleDevPanel,
|
||||||
|
child: Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.cosmicAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _devOpen
|
||||||
|
? ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
|
children: [
|
||||||
|
if (widget.auth.isMockSessionAvailable) ...[
|
||||||
|
_MockBanner(
|
||||||
|
onEnter: widget.auth.signInWithMockSession,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s4),
|
||||||
|
],
|
||||||
|
if (widget.auth.qaEmailProvider != null)
|
||||||
|
_EmailForm(
|
||||||
|
formKey: _form,
|
||||||
|
email: _email,
|
||||||
|
password: _password,
|
||||||
|
signUp: _signUp,
|
||||||
|
busy: _busy,
|
||||||
|
onSubmit: _submitEmail,
|
||||||
|
onToggleMode: () =>
|
||||||
|
setState(() => _signUp = !_signUp),
|
||||||
|
onReset: _resetPassword,
|
||||||
|
),
|
||||||
|
if (_notice != null) ...[
|
||||||
|
const SizedBox(height: AppSpace.s3),
|
||||||
|
AuthNoticeBox(_notice!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => _open(signUp: false),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.cosmicAccent,
|
||||||
|
foregroundColor: AppColors.onCosmicAccent,
|
||||||
|
),
|
||||||
|
child: const Text('로그인'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s4),
|
||||||
|
AuthSwitchRow(
|
||||||
|
question: '팬우주는 처음이세요?',
|
||||||
|
action: '회원가입',
|
||||||
|
onTap: () => _open(signUp: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 간편 로그인(`173:2183`) · 회원가입(`177:9451`).
|
||||||
|
///
|
||||||
|
/// 두 화면은 문구만 다르다 — 「로그인」↔「가입하기」, 하단 전환 문구. 가입도 소셜 제공자
|
||||||
|
/// 인증이 계정을 만드는 것이라 같은 [AuthRepository.signIn] 을 부르고, 그 앞에 약관
|
||||||
|
/// 동의([TermsScreen], `179:24230`)를 거친다.
|
||||||
|
class SimpleLoginScreen extends StatefulWidget {
|
||||||
|
const SimpleLoginScreen({super.key, required this.auth, this.signUp = false});
|
||||||
|
|
||||||
|
final AuthRepository auth;
|
||||||
|
final bool signUp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SimpleLoginScreen> createState() => _SimpleLoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||||
|
String? _notice;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
Future<void> _signIn(SocialProvider provider) async {
|
Future<void> _signIn(SocialProvider provider) async {
|
||||||
|
if (widget.signUp) {
|
||||||
|
final agreed = await Navigator.of(
|
||||||
|
context,
|
||||||
|
).push<bool>(MaterialPageRoute(builder: (_) => const TermsScreen()));
|
||||||
|
if (agreed != true || !mounted) return;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -102,11 +226,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _notice = _noticeOf(outcome));
|
setState(() => _notice = authNotice(outcome));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
|
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
|
||||||
final name = outcome.provider.label.replaceAll('로 시작하기', '');
|
final name = outcome.provider.shortName;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -125,180 +249,243 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
|
void _switchMode() {
|
||||||
///
|
Navigator.of(context).pushReplacement(
|
||||||
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
|
MaterialPageRoute<void>(
|
||||||
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
|
builder: (_) =>
|
||||||
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
|
SimpleLoginScreen(auth: widget.auth, signUp: !widget.signUp),
|
||||||
VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null
|
),
|
||||||
? null
|
);
|
||||||
: () => setState(() => _qaFormOpen = !_qaFormOpen);
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final text = Theme.of(context).textTheme;
|
final signUp = widget.signUp;
|
||||||
|
// 버튼 순서는 시안(Apple · Google · Kakao) 그대로, 시안에 없는 LINE 은 끝에 붙인다.
|
||||||
|
final providers = [
|
||||||
|
for (final kind in _figmaOrder)
|
||||||
|
...widget.auth.providers.where((p) => p.provider == kind),
|
||||||
|
];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const ImageIcon(AppIcons.x, size: 24),
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpace.s1),
|
||||||
|
],
|
||||||
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
top: false,
|
||||||
padding: const EdgeInsets.all(AppSpace.s5),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
const SizedBox(height: AppSpace.s8),
|
child: Center(
|
||||||
// 시안 로그인(173:1905)은 화면 위쪽 가운데에 핑크 원(로고 자리) 하나만 둔다.
|
child: Column(
|
||||||
// 로고 원본은 미확정(Q6)이라 원 안에 워드마크를 텍스트로 넣는다.
|
mainAxisSize: MainAxisSize.min,
|
||||||
Center(
|
children: [
|
||||||
child: GestureDetector(
|
Text(
|
||||||
onLongPress: _openQaForm,
|
'FANUZU Account',
|
||||||
child: Container(
|
style: _abz(14, AppColors.cosmicAccent),
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.cosmicAccent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
const SizedBox(height: AppSpace.s5),
|
||||||
child: Text(
|
Text(
|
||||||
'FANUZU',
|
signUp
|
||||||
style: text.titleLarge?.copyWith(
|
? '가장 편한 방법으로\n팬우주에 회원가입 하세요!'
|
||||||
color: AppColors.onCosmicAccent,
|
: '가장 편한 방법으로\n팬우주에 로그인하세요!',
|
||||||
fontFamily: AppFonts.display,
|
textAlign: TextAlign.center,
|
||||||
fontWeight: FontWeight.w700,
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.4,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpace.s6),
|
),
|
||||||
Text(
|
Padding(
|
||||||
'당신의 팬 활동을\n하나의 행성으로',
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
style: text.headlineSmall?.copyWith(
|
child: Column(
|
||||||
fontWeight: FontWeight.w500,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
),
|
children: [
|
||||||
textAlign: TextAlign.center,
|
for (final p in providers) ...[
|
||||||
|
_ProviderButton(
|
||||||
|
provider: p.provider,
|
||||||
|
label: providerButtonLabel(p.provider, signUp: signUp),
|
||||||
|
enabled: !_busy,
|
||||||
|
onPressed: () => _signIn(p.provider),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s4),
|
||||||
|
],
|
||||||
|
if (_notice != null) ...[
|
||||||
|
AuthNoticeBox(_notice!),
|
||||||
|
const SizedBox(height: AppSpace.s4),
|
||||||
|
],
|
||||||
|
signUp
|
||||||
|
? AuthSwitchRow(
|
||||||
|
question: '이미 가입하셨나요?',
|
||||||
|
action: '로그인',
|
||||||
|
onTap: _switchMode,
|
||||||
|
)
|
||||||
|
: AuthSwitchRow(
|
||||||
|
question: '팬우주는 처음이세요?',
|
||||||
|
action: '회원가입',
|
||||||
|
onTap: _switchMode,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpace.s3),
|
),
|
||||||
Text(
|
],
|
||||||
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
|
|
||||||
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),
|
|
||||||
const SizedBox(height: AppSpace.s4),
|
|
||||||
for (final p in widget.auth.providers) ...[
|
|
||||||
_ProviderButton(
|
|
||||||
provider: p.provider,
|
|
||||||
// 발주사 키가 아직 없는 제공자. 눌러도 안내 다이얼로그로 닫힌다.
|
|
||||||
awaitingKeys: p.missingConfig.isNotEmpty,
|
|
||||||
enabled: !_busy,
|
|
||||||
onPressed: () => _signIn(p.provider),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpace.s3),
|
|
||||||
],
|
|
||||||
if (_qaFormOpen) ...[
|
|
||||||
const SizedBox(height: AppSpace.s2),
|
|
||||||
_EmailForm(
|
|
||||||
formKey: _form,
|
|
||||||
email: _email,
|
|
||||||
password: _password,
|
|
||||||
signUp: _signUp,
|
|
||||||
busy: _busy,
|
|
||||||
onSubmit: _submitEmail,
|
|
||||||
onToggleMode: () => setState(() => _signUp = !_signUp),
|
|
||||||
onReset: _resetPassword,
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpace.s3),
|
|
||||||
],
|
|
||||||
if (_notice != null) ...[
|
|
||||||
const SizedBox(height: AppSpace.s2),
|
|
||||||
_Notice(_notice!),
|
|
||||||
],
|
|
||||||
if (widget.auth.isMockSessionAvailable) ...[
|
|
||||||
const SizedBox(height: AppSpace.s5),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: AppSpace.s4),
|
|
||||||
_MockBanner(onEnter: widget.auth.signInWithMockSession),
|
|
||||||
],
|
|
||||||
const SizedBox(height: AppSpace.s6),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 소셜 버튼. 색과 라벨은 각 사 브랜드 가이드 고정값이라 테마를 타지 않는다.
|
const _figmaOrder = [
|
||||||
|
AuthProviderKind.apple,
|
||||||
|
AuthProviderKind.google,
|
||||||
|
AuthProviderKind.kakao,
|
||||||
|
AuthProviderKind.line,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 간편 로그인 · 회원가입 버튼 문구 — 시안 「Kakao로 로그인」 · 「Kakao로 가입하기」.
|
||||||
|
/// [AuthProviderKind.label](「카카오로 시작하기」)은 설정 · 안내 문구가 쓰므로 따로 둔다.
|
||||||
|
String providerButtonLabel(AuthProviderKind kind, {bool signUp = false}) =>
|
||||||
|
'${_names[kind]} ${signUp ? '가입하기' : '로그인'}';
|
||||||
|
|
||||||
|
/// 시안 버튼 문구의 제공자 표기. LINE 은 조사가 「으로」다.
|
||||||
|
const _names = {
|
||||||
|
AuthProviderKind.apple: 'Apple로',
|
||||||
|
AuthProviderKind.google: 'Google로',
|
||||||
|
AuthProviderKind.kakao: 'Kakao로',
|
||||||
|
AuthProviderKind.line: 'LINE으로',
|
||||||
|
AuthProviderKind.email: '이메일로',
|
||||||
|
};
|
||||||
|
|
||||||
|
String? authNotice(AuthOutcome outcome) => switch (outcome) {
|
||||||
|
AuthSucceeded() => null,
|
||||||
|
AuthCancelled() => null,
|
||||||
|
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||||
|
AuthNotConfigured(:final provider, :final missing) =>
|
||||||
|
'${provider.shortName} 로그인은 아직 준비 중입니다.\n'
|
||||||
|
'필요한 항목: ${missing.join(' · ')}',
|
||||||
|
};
|
||||||
|
|
||||||
|
TextStyle _abz(double size, Color color) => TextStyle(
|
||||||
|
fontFamily: AppFonts.display,
|
||||||
|
fontFamilyFallback: const [AppFonts.body],
|
||||||
|
fontSize: size,
|
||||||
|
height: 24 / size,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 하단 전환 문구 — 「팬우주는 처음이세요? 회원가입」. 시안 ABeeZee 16 #cbcbcb + 42dot 16/600.
|
||||||
|
class AuthSwitchRow extends StatelessWidget {
|
||||||
|
const AuthSwitchRow({
|
||||||
|
super.key,
|
||||||
|
required this.question,
|
||||||
|
required this.action,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String question;
|
||||||
|
final String action;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(question, style: _abz(16, const Color(0xFFCBCBCB))),
|
||||||
|
const SizedBox(width: AppSpace.s2),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Text(
|
||||||
|
action,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 제공자 버튼 — 시안 343×56 · 검정 바탕 · #545454 획 · 모서리 8 · 왼쪽 브랜드 아이콘 24.
|
||||||
|
///
|
||||||
|
/// 발주사 키가 없는 제공자도 **비활성으로 만들지 않는다** — 눌러야 무엇이 없어서
|
||||||
|
/// 못 쓰는지 안내 다이얼로그가 뜬다. 회색 버튼은 사유를 말해 주지 않는다.
|
||||||
class _ProviderButton extends StatelessWidget {
|
class _ProviderButton extends StatelessWidget {
|
||||||
const _ProviderButton({
|
const _ProviderButton({
|
||||||
required this.provider,
|
required this.provider,
|
||||||
required this.awaitingKeys,
|
required this.label,
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
});
|
});
|
||||||
|
|
||||||
final SocialProvider provider;
|
final SocialProvider provider;
|
||||||
|
final String label;
|
||||||
/// 「키 대기」 배지를 붙일지. **버튼을 비활성으로 만들지 않는다** — 눌러야
|
|
||||||
/// 무엇이 없어서 못 쓰는지 안내가 뜬다. 회색 버튼은 사유를 말해 주지 않는다.
|
|
||||||
final bool awaitingKeys;
|
|
||||||
|
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
static const _icons = {
|
||||||
|
AuthProviderKind.apple: AssetImage('assets/icons/brand_apple.png'),
|
||||||
|
AuthProviderKind.google: AssetImage('assets/icons/brand_google.png'),
|
||||||
|
AuthProviderKind.kakao: AssetImage('assets/icons/brand_kakao.png'),
|
||||||
|
AuthProviderKind.line: AssetImage('assets/icons/brand_line.png'),
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final (fill, label, stroke) = switch (provider) {
|
final icon = _icons[provider];
|
||||||
SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null),
|
|
||||||
SocialProvider.line => (BrandColors.line, BrandColors.lineLabel, null),
|
|
||||||
SocialProvider.email => (BrandColors.email, BrandColors.emailLabel, null),
|
|
||||||
SocialProvider.apple => (
|
|
||||||
BrandColors.apple,
|
|
||||||
BrandColors.appleLabel,
|
|
||||||
BrandColors.appleOnlight,
|
|
||||||
),
|
|
||||||
SocialProvider.google => (
|
|
||||||
BrandColors.googleFill,
|
|
||||||
BrandColors.googleLabel,
|
|
||||||
BrandColors.googleStroke,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
return FilledButton(
|
return FilledButton(
|
||||||
onPressed: enabled ? onPressed : null,
|
onPressed: enabled ? onPressed : null,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: fill,
|
backgroundColor: Colors.black,
|
||||||
foregroundColor: label,
|
foregroundColor: AppColors.onCosmic,
|
||||||
disabledBackgroundColor: fill.withValues(alpha: 0.4),
|
disabledBackgroundColor: Colors.black,
|
||||||
side: stroke == null ? null : BorderSide(color: stroke),
|
disabledForegroundColor: AppColors.onCosmicMuted,
|
||||||
|
side: const BorderSide(color: Color(0xFF545454)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
SizedBox.square(
|
||||||
child: Text(provider.label, overflow: TextOverflow.ellipsis),
|
dimension: 24,
|
||||||
|
child: icon == null
|
||||||
|
? null
|
||||||
|
: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||||
|
child: Image(image: icon, width: 24, height: 24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (awaitingKeys) ...[
|
Expanded(
|
||||||
const SizedBox(width: AppSpace.s2),
|
child: Text(
|
||||||
Text(
|
label,
|
||||||
keyPendingBadge,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: label.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Notice extends StatelessWidget {
|
class AuthNoticeBox extends StatelessWidget {
|
||||||
const _Notice(this.message);
|
const AuthNoticeBox(this.message, {super.key});
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@@ -414,7 +601,7 @@ class _EmailForm extends StatelessWidget {
|
|||||||
const SizedBox(height: AppSpace.s4),
|
const SizedBox(height: AppSpace.s4),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: busy ? null : onSubmit,
|
onPressed: busy ? null : onSubmit,
|
||||||
child: Text(signUp ? '가입하기' : '로그인'),
|
child: Text(signUp ? '가입하기' : '이메일 로그인'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpace.s2),
|
const SizedBox(height: AppSpace.s2),
|
||||||
Row(
|
Row(
|
||||||
@@ -438,10 +625,6 @@ class _EmailForm extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지.
|
|
||||||
/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다.
|
|
||||||
const String keyPendingBadge = '키 대기';
|
|
||||||
|
|
||||||
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
|
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
|
||||||
abstract final class EmailFormMetrics {
|
abstract final class EmailFormMetrics {
|
||||||
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
|
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/tokens.dart';
|
||||||
|
|
||||||
|
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
|
||||||
|
///
|
||||||
|
/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를
|
||||||
|
/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다.
|
||||||
|
///
|
||||||
|
/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다.
|
||||||
|
class TermsScreen extends StatefulWidget {
|
||||||
|
const TermsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TermsScreen> createState() => _TermsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Term {
|
||||||
|
service('서비스 이용약관 (필수)', required: true, body: true),
|
||||||
|
location('위치기반 서비스 이용약관 (필수)', required: true, body: true),
|
||||||
|
age('만 14세 이상입니다. (필수)', required: true, body: false),
|
||||||
|
marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true);
|
||||||
|
|
||||||
|
const _Term(this.label, {required this.required, required this.body});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool required;
|
||||||
|
final bool body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _body =
|
||||||
|
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
|
||||||
|
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
|
||||||
|
|
||||||
|
const _stroke = Color(0xFF545454);
|
||||||
|
|
||||||
|
class _TermsScreenState extends State<TermsScreen> {
|
||||||
|
final _agreed = <_Term>{};
|
||||||
|
|
||||||
|
bool get _all => _agreed.length == _Term.values.length;
|
||||||
|
bool get _ready =>
|
||||||
|
_Term.values.where((t) => t.required).every(_agreed.contains);
|
||||||
|
|
||||||
|
void _toggle(_Term t) =>
|
||||||
|
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('이용 약관')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
|
children: [
|
||||||
|
_Box(
|
||||||
|
child: _CheckRow(
|
||||||
|
label: '전체 동의',
|
||||||
|
checked: _all,
|
||||||
|
size: 16,
|
||||||
|
onTap: () => setState(
|
||||||
|
() => _all ? _agreed.clear() : _agreed.addAll(_Term.values),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final t in _Term.values) ...[
|
||||||
|
const SizedBox(height: AppSpace.s3),
|
||||||
|
_Box(
|
||||||
|
child: t.body
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
// 시안 획은 안쪽이다 — decoration 이면 획 두께만큼 높이가 는다.
|
||||||
|
foregroundDecoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: _stroke)),
|
||||||
|
),
|
||||||
|
child: _CheckRow(
|
||||||
|
label: t.label,
|
||||||
|
checked: _agreed.contains(t),
|
||||||
|
onTap: () => _toggle(t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: AppSpace.s4),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: ClipRect(
|
||||||
|
child: Text(
|
||||||
|
_body,
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppFonts.display,
|
||||||
|
fontFamilyFallback: [AppFonts.body],
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.36,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _CheckRow(
|
||||||
|
label: t.label,
|
||||||
|
checked: _agreed.contains(t),
|
||||||
|
onTap: () => _toggle(t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: AppSpace.s6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
child: const Text('이전'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _ready
|
||||||
|
? () => Navigator.of(context).pop(true)
|
||||||
|
: null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.cosmicAccent,
|
||||||
|
foregroundColor: AppColors.onCosmicAccent,
|
||||||
|
disabledBackgroundColor: const Color(0xFF333333),
|
||||||
|
disabledForegroundColor: const Color(0xFF777777),
|
||||||
|
),
|
||||||
|
child: const Text('완료'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 약관 칸 — #545454 획 · 모서리 8 · 좌우 16.
|
||||||
|
class _Box extends StatelessWidget {
|
||||||
|
const _Box({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||||
|
foregroundDecoration: BoxDecoration(
|
||||||
|
border: Border.all(color: _stroke),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 체크 행 — circle-check 20 · 간격 16 · ABeeZee. 미동의는 #767676, 동의는 핑크.
|
||||||
|
class _CheckRow extends StatelessWidget {
|
||||||
|
const _CheckRow({
|
||||||
|
required this.label,
|
||||||
|
required this.checked,
|
||||||
|
required this.onTap,
|
||||||
|
this.size = 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool checked;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ImageIcon(
|
||||||
|
const AssetImage('assets/icons/circle_check.png'),
|
||||||
|
size: 20,
|
||||||
|
color: checked ? AppColors.cosmicAccent : AppColors.onCosmicMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpace.s4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppFonts.display,
|
||||||
|
fontFamilyFallback: const [AppFonts.body],
|
||||||
|
fontSize: size,
|
||||||
|
height: size == 16 ? 24 / 16 : 20 / 14,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 회원가입 완료 — Figma `182:29344`. 「시작」은 이 화면을 닫는다.
|
||||||
|
///
|
||||||
|
/// 아직 가입 동선에 걸려 있지 않다. 제공자 인증이 성공하면 라우터 redirect 가 곧장 셸로
|
||||||
|
/// 보내므로, 이 화면을 끼우려면 redirect 가 「가입 직후」를 알아야 한다(app_router 공유 변경).
|
||||||
|
class SignupCompleteScreen extends StatelessWidget {
|
||||||
|
const SignupCompleteScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: 120,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cosmicAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s6),
|
||||||
|
const Text(
|
||||||
|
'회원가입이 완료되었습니다',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s3),
|
||||||
|
Text(
|
||||||
|
'팬덤 활동을 시작할 수 있습니다.\n다양한 활동을 통해 내 팬덤 행성을 키워보세요.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppFonts.display,
|
||||||
|
fontFamilyFallback: const [AppFonts.body],
|
||||||
|
fontSize: 14,
|
||||||
|
height: 24 / 14,
|
||||||
|
color: const Color(0xFFCDCDCD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.cosmicAccent,
|
||||||
|
foregroundColor: AppColors.onCosmicAccent,
|
||||||
|
),
|
||||||
|
child: const Text('시작'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,10 @@ final class GoogleSignInProvider
|
|||||||
}
|
}
|
||||||
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error),
|
||||||
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +182,10 @@ final class AppleSignInProvider
|
|||||||
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
|
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
|
||||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error),
|
||||||
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,28 +284,35 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthOutcome> signInWithEmail(String email, String password) =>
|
Future<AuthOutcome> signInWithEmail(String email, String password) => _run(
|
||||||
_run(() => fb.FirebaseAuth.instance.signInWithEmailAndPassword(
|
() => fb.FirebaseAuth.instance.signInWithEmailAndPassword(
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password: password,
|
password: password,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthOutcome> signUpWithEmail(String email, String password) =>
|
Future<AuthOutcome> signUpWithEmail(String email, String password) => _run(
|
||||||
_run(() => fb.FirebaseAuth.instance.createUserWithEmailAndPassword(
|
() => fb.FirebaseAuth.instance.createUserWithEmailAndPassword(
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password: password,
|
password: password,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthOutcome> sendPasswordReset(String email) async {
|
Future<AuthOutcome> sendPasswordReset(String email) async {
|
||||||
final missing = missingConfig;
|
final missing = missingConfig;
|
||||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
try {
|
try {
|
||||||
await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim());
|
await fb.FirebaseAuth.instance.sendPasswordResetEmail(
|
||||||
|
email: email.trim(),
|
||||||
|
);
|
||||||
return const AuthCancelled();
|
return const AuthCancelled();
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error),
|
||||||
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +323,10 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
final result = await action();
|
final result = await action();
|
||||||
return AuthSucceeded(_toAccount(result.user, provider));
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error),
|
||||||
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +353,9 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|||||||
'weak-password' => 'E-AUTH-05',
|
'weak-password' => 'E-AUTH-05',
|
||||||
'invalid-email' => 'E-AUTH-06',
|
'invalid-email' => 'E-AUTH-06',
|
||||||
'too-many-requests' => 'E-AUTH-07',
|
'too-many-requests' => 'E-AUTH-07',
|
||||||
// 계정 연결(별첨 B v1.4) 경로에서만 나오는 코드들.
|
// credential-already-in-use 는 계정 연결 경로에서만 나오지만,
|
||||||
|
// account-exists-with-different-credential 은 로그인 경로에서도 난다.
|
||||||
|
// 카탈로그 ID 는 같고 문구만 경로별로 갈린다(authMessage 참고).
|
||||||
'credential-already-in-use' ||
|
'credential-already-in-use' ||
|
||||||
'account-exists-with-different-credential' => 'E-AUTH-08',
|
'account-exists-with-different-credential' => 'E-AUTH-08',
|
||||||
'provider-already-linked' => 'E-AUTH-09',
|
'provider-already-linked' => 'E-AUTH-09',
|
||||||
@@ -369,26 +387,24 @@ enum AuthFlow {
|
|||||||
/// 그 제공자로 들어가 달라고 안내해야 하고, **계정 연결** 경로에서는 「이 소셜
|
/// 그 제공자로 들어가 달라고 안내해야 하고, **계정 연결** 경로에서는 「이 소셜
|
||||||
/// 계정이 다른 FANUZU 계정에 이미 붙어 있다」다. 한 벌로 적으면 로그인에 실패한
|
/// 계정이 다른 FANUZU 계정에 이미 붙어 있다」다. 한 벌로 적으면 로그인에 실패한
|
||||||
/// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다.
|
/// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다.
|
||||||
String authMessage(
|
String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
||||||
fb.FirebaseAuthException error, {
|
switch (error.code) {
|
||||||
required AuthFlow flow,
|
'email-already-in-use' => '이미 가입된 이메일입니다.',
|
||||||
}) => switch (error.code) {
|
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.',
|
||||||
'email-already-in-use' => '이미 가입된 이메일입니다.',
|
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
||||||
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.',
|
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
||||||
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
'account-exists-with-different-credential' => switch (flow) {
|
||||||
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
|
||||||
'account-exists-with-different-credential' => switch (flow) {
|
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
|
},
|
||||||
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
||||||
},
|
'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
'provider-already-linked' => '이미 연결된 계정입니다.',
|
||||||
'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
||||||
'provider-already-linked' => '이미 연결된 계정입니다.',
|
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
||||||
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||||
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
};
|
||||||
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
||||||
///
|
///
|
||||||
@@ -401,7 +417,11 @@ String authMessage(
|
|||||||
/// 시도한 자격증명이고, 기존 제공자를 알려면 `fetchSignInMethodsForEmail` 이
|
/// 시도한 자격증명이고, 기존 제공자를 알려면 `fetchSignInMethodsForEmail` 이
|
||||||
/// 필요한데 이메일 열거 방지가 켜지면 빈 목록을 돌려준다. 이름까지 띄워야 하면
|
/// 필요한데 이메일 열거 방지가 켜지면 빈 목록을 돌려준다. 이름까지 띄워야 하면
|
||||||
/// 서버에서 Admin SDK 로 조회해 내려주는 쪽으로 올린다.
|
/// 서버에서 Admin SDK 로 조회해 내려주는 쪽으로 올린다.
|
||||||
|
///
|
||||||
|
/// 이메일을 조사 앞에 바로 붙이지 않는다. 도메인 끝 글자가 모음인지 자음인지에
|
||||||
|
/// 따라 「은/는」이 갈려 `example.co은` 같은 문구가 나간다. 「계정」을 사이에 두면
|
||||||
|
/// 조사가 항상 맞는다.
|
||||||
String _otherProviderSignInGuide(String? email) =>
|
String _otherProviderSignInGuide(String? email) =>
|
||||||
'${email == null ? '이 계정' : email}은 다른 로그인 수단으로 가입돼 있습니다. '
|
'${email ?? '이'} 계정은 다른 로그인 수단으로 가입돼 있습니다. '
|
||||||
'처음 가입에 쓰신 수단으로 로그인한 뒤 MY > 설정 > SNS 연동에서 '
|
'처음 가입에 쓰신 수단으로 로그인한 뒤 MY > 설정 > SNS 연동에서 '
|
||||||
'이 수단을 연결해 주세요.';
|
'이 수단을 연결해 주세요.';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:fancake/core/firebase/firebase_boot.dart';
|
|||||||
import 'package:fancake/core/router/app_router.dart';
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
import 'package:fancake/features/auth/auth.dart';
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
import 'package:fancake/features/auth/auth_repository.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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -24,11 +25,13 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(find.text('FANUZU'), findsOneWidget);
|
expect(find.byKey(loginLogoKey), findsOneWidget);
|
||||||
// 별첨 B v1.4 확정 4종이 라벨 그대로 있다.
|
// Figma 2단계 — 「로그인」을 누르면 간편 로그인에 별첨 B v1.4 확정 4종이 있다.
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
for (final provider in AuthRepository().providers) {
|
for (final provider in AuthRepository().providers) {
|
||||||
expect(
|
expect(
|
||||||
find.text(provider.provider.label),
|
find.text(providerButtonLabel(provider.provider)),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
reason: provider.provider.name,
|
reason: provider.provider.name,
|
||||||
);
|
);
|
||||||
@@ -189,7 +192,12 @@ void main() {
|
|||||||
reason: '셸에 진입할 수 없어야 한다',
|
reason: '셸에 진입할 수 없어야 한다',
|
||||||
);
|
);
|
||||||
// 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다.
|
// 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다.
|
||||||
expect(find.text(SocialProvider.google.label), findsOneWidget);
|
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.text(providerButtonLabel(SocialProvider.google)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async {
|
testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async {
|
||||||
@@ -197,6 +205,9 @@ void main() {
|
|||||||
FancakeApp(auth: AuthRepository(releaseMode: false)),
|
FancakeApp(auth: AuthRepository(releaseMode: false)),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
// 목 세션 입구는 QA 폼과 같은 숨은 입구(로고 길게 누르기) 안에 있다.
|
||||||
|
await tester.longPress(find.byKey(loginLogoKey));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget);
|
expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget);
|
||||||
});
|
});
|
||||||
@@ -288,6 +299,8 @@ void main() {
|
|||||||
Future<void> _enterShell(WidgetTester tester) async {
|
Future<void> _enterShell(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
|
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.longPress(find.byKey(loginLogoKey));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final enter = find.text('목 세션으로 탭 둘러보기');
|
final enter = find.text('목 세션으로 탭 둘러보기');
|
||||||
await tester.ensureVisible(enter);
|
await tester.ensureVisible(enter);
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ void main() {
|
|||||||
|
|
||||||
/// 숨은 입구 — 로고를 길게 누른다. 보이는 진입점은 두지 않는다.
|
/// 숨은 입구 — 로고를 길게 누른다. 보이는 진입점은 두지 않는다.
|
||||||
Future<void> openQaForm(WidgetTester tester) async {
|
Future<void> openQaForm(WidgetTester tester) async {
|
||||||
await tester.longPress(find.text('FANUZU'));
|
await tester.longPress(find.byKey(loginLogoKey));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ void main() {
|
|||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(home: LoginScreen(auth: AuthRepository(releaseMode: true))),
|
MaterialApp(home: LoginScreen(auth: AuthRepository(releaseMode: true))),
|
||||||
);
|
);
|
||||||
await tester.longPress(find.text('FANUZU'));
|
await tester.longPress(find.byKey(loginLogoKey));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('이메일'), findsNothing);
|
expect(find.text('이메일'), findsNothing);
|
||||||
expect(find.text('비밀번호'), findsNothing);
|
expect(find.text('비밀번호'), findsNothing);
|
||||||
@@ -198,7 +198,7 @@ void main() {
|
|||||||
await pump(tester, fake);
|
await pump(tester, fake);
|
||||||
await openQaForm(tester);
|
await openQaForm(tester);
|
||||||
|
|
||||||
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
|
await tapVisible(tester, find.widgetWithText(FilledButton, '이메일 로그인'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('이메일을 입력해 주세요.'), findsOneWidget);
|
expect(find.text('이메일을 입력해 주세요.'), findsOneWidget);
|
||||||
expect(fake.lastEmail, isNull);
|
expect(fake.lastEmail, isNull);
|
||||||
@@ -211,7 +211,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr');
|
await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr');
|
||||||
await tester.enterText(find.byType(TextFormField).last, 'password12');
|
await tester.enterText(find.byType(TextFormField).last, 'password12');
|
||||||
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
|
await tapVisible(tester, find.widgetWithText(FilledButton, '이메일 로그인'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('이메일 형식이 올바르지 않습니다.'), findsOneWidget);
|
expect(find.text('이메일 형식이 올바르지 않습니다.'), findsOneWidget);
|
||||||
expect(fake.lastEmail, isNull);
|
expect(fake.lastEmail, isNull);
|
||||||
@@ -248,7 +248,7 @@ void main() {
|
|||||||
'fan@fanuzu.co.kr',
|
'fan@fanuzu.co.kr',
|
||||||
);
|
);
|
||||||
await tester.enterText(find.byType(TextFormField).last, 'password12');
|
await tester.enterText(find.byType(TextFormField).last, 'password12');
|
||||||
await tapVisible(tester, find.widgetWithText(FilledButton, '로그인'));
|
await tapVisible(tester, find.widgetWithText(FilledButton, '이메일 로그인'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(fake.lastEmail, 'fan@fanuzu.co.kr');
|
expect(fake.lastEmail, 'fan@fanuzu.co.kr');
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 오류 응답의 이메일 힌트를 그대로 넣어야 어느 계정인지 알 수 있다.
|
// 오류 응답의 이메일 힌트를 그대로 넣어야 어느 계정인지 알 수 있다.
|
||||||
expect(message, contains('fan@gmail.com'));
|
// 이메일에 조사를 바로 붙이지 않는다 — 도메인 끝 글자에 따라 은/는 이 갈린다.
|
||||||
|
expect(message, startsWith('fan@gmail.com 계정은'));
|
||||||
expect(message, contains('처음 가입에 쓰신 수단으로 로그인'));
|
expect(message, contains('처음 가입에 쓰신 수단으로 로그인'));
|
||||||
expect(message, contains('SNS 연동'));
|
expect(message, contains('SNS 연동'));
|
||||||
// 로그인 경로에서는 「연결된 소셜 계정」이 사실이 아니다.
|
// 로그인 경로에서는 「연결된 소셜 계정」이 사실이 아니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user