636 lines
22 KiB
Dart
636 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/ui/app_dialog.dart';
|
|
|
|
import '../../core/firebase/firebase_boot.dart';
|
|
import '../../core/i18n/app_locale.dart';
|
|
import '../../core/theme/tokens.dart';
|
|
import '../../core/ui/app_icons.dart';
|
|
import 'auth.dart';
|
|
import 'auth_repository.dart';
|
|
import 'signup_screens.dart';
|
|
|
|
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:1905`) → 「간편 로그인」(`173:2183`) 2단계.
|
|
///
|
|
/// 첫 화면은 로고 원과 「로그인」 버튼뿐이고, 제공자 선택은 다음 화면([SimpleLoginScreen])이다.
|
|
/// 「회원가입」은 같은 틀의 가입 화면(`177:9451`)으로 간다.
|
|
///
|
|
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
|
|
/// Figma 의 「Fancake로 로그인」(이메일) · 「X(Twitter)로 로그인」은 같은 회신이 1차 범위에서
|
|
/// 뺐으므로 그리지 않고, 비밀번호 재설정 동선도 없다.
|
|
///
|
|
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
|
|
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_toggleDevPanel]). 목 세션 입구도 같은 곳에 둔다.
|
|
class LoginScreen extends StatefulWidget {
|
|
const LoginScreen({super.key, required this.auth});
|
|
|
|
final AuthRepository auth;
|
|
|
|
@override
|
|
State<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
/// 로고 원. 숨은 입구를 찾는 테스트가 이 키로 누른다.
|
|
const loginLogoKey = Key('login-logo');
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
final _form = GlobalKey<FormState>();
|
|
|
|
String? _notice;
|
|
bool _busy = false;
|
|
|
|
/// 개발용 패널(QA 이메일 폼 · 목 세션)이 열렸는가. 숨은 입구로만 열린다.
|
|
bool _devOpen = false;
|
|
|
|
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
|
|
bool _signUp = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_email.dispose();
|
|
_password.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submitEmail() async {
|
|
if (!(_form.currentState?.validate() ?? false)) return;
|
|
setState(() => _busy = true);
|
|
final outcome = await widget.auth.signInWithEmail(
|
|
email: _email.text,
|
|
password: _password.text,
|
|
signUp: _signUp,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_busy = false;
|
|
_notice = authNotice(outcome, context.l10n);
|
|
});
|
|
}
|
|
|
|
Future<void> _resetPassword() async {
|
|
final email = _email.text.trim();
|
|
if (email.isEmpty) {
|
|
setState(() => _notice = context.l10n.authResetNeedEmail);
|
|
return;
|
|
}
|
|
final impl = widget.auth.qaEmailProvider;
|
|
if (impl == null) return;
|
|
setState(() => _busy = true);
|
|
final outcome = await impl.sendPasswordReset(email);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_busy = false;
|
|
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
|
|
_notice = outcome is AuthFailed
|
|
? authNotice(outcome, context.l10n)
|
|
: context.l10n.authResetSent;
|
|
});
|
|
}
|
|
|
|
/// 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
|
|
///
|
|
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
|
|
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
|
|
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
|
|
VoidCallback? get _toggleDevPanel =>
|
|
widget.auth.qaEmailProvider == null && !widget.auth.isMockSessionAvailable
|
|
? null
|
|
: () => setState(() => _devOpen = !_devOpen);
|
|
|
|
void _open({required bool signUp}) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
// 명세 `510:86374` 「닫기 — 아래 방향 Move Out 으로 이전 화면」 — 아래에서 올라오는 전체 화면.
|
|
fullscreenDialog: true,
|
|
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). 명세 `510:86330` 「컨테인 중앙 정렬」.
|
|
// 로고는 발주사 인수 자산 `fanuzu logo only.png` 의 마크(공통 기준 4항 FANUZU).
|
|
SizedBox(
|
|
height: 386,
|
|
child: Center(
|
|
child: GestureDetector(
|
|
key: loginLogoKey,
|
|
onLongPress: _toggleDevPanel,
|
|
child: Image.asset(
|
|
'assets/brand/fanuzu-mark.png',
|
|
width: 120,
|
|
height: 120,
|
|
fit: BoxFit.contain,
|
|
semanticLabel: 'FANUZU',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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: Text(context.l10n.authLogin),
|
|
),
|
|
const SizedBox(height: AppSpace.s4),
|
|
AuthSwitchRow(
|
|
question: context.l10n.authFirstTime,
|
|
action: context.l10n.authSignup,
|
|
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 {
|
|
if (widget.signUp) {
|
|
final agreed = await Navigator.of(context).push<Set<String>>(
|
|
MaterialPageRoute(builder: (_) => const TermsScreen()),
|
|
);
|
|
if (agreed == null || !mounted) return;
|
|
// 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다).
|
|
widget.auth.pendingConsent = agreed;
|
|
}
|
|
setState(() => _busy = true);
|
|
final outcome = await widget.auth.signIn(provider, signUp: widget.signUp);
|
|
if (!mounted) return;
|
|
setState(() => _busy = false);
|
|
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
|
|
// 사용자는 앱이 멈춘 줄 안다.
|
|
if (outcome is AuthNotConfigured) {
|
|
await _showNotConfigured(outcome);
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _notice = authNotice(outcome, context.l10n));
|
|
}
|
|
|
|
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
|
|
final name = outcome.provider.shortName;
|
|
await showAlertDialog(
|
|
context,
|
|
context.l10n.authNotConfiguredTitle(name),
|
|
// 설정 항목(outcome.missing)은 개발자용이라 화면에 내보내지 않는다.
|
|
body: context.l10n.authNotConfiguredBody(name),
|
|
);
|
|
}
|
|
|
|
void _switchMode() {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
fullscreenDialog: true,
|
|
builder: (_) =>
|
|
SimpleLoginScreen(auth: widget.auth, signUp: !widget.signUp),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(
|
|
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(
|
|
top: false,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'FANUZU Account',
|
|
style: _abz(14, AppColors.cosmicAccent),
|
|
),
|
|
const SizedBox(height: AppSpace.s5),
|
|
Text(
|
|
signUp
|
|
? context.l10n.authSignupHeadline
|
|
: context.l10n.authLoginHeadline,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: AppFonts.body,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.4,
|
|
color: AppColors.onCosmic,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppSpace.s4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (final p in providers) ...[
|
|
_ProviderButton(
|
|
provider: p.provider,
|
|
label: providerButtonLabel(
|
|
p.provider,
|
|
signUp: signUp,
|
|
l10n: context.l10n,
|
|
),
|
|
enabled: !_busy,
|
|
onPressed: () => _signIn(p.provider),
|
|
),
|
|
const SizedBox(height: AppSpace.s4),
|
|
],
|
|
if (_notice != null) ...[
|
|
AuthNoticeBox(_notice!),
|
|
const SizedBox(height: AppSpace.s4),
|
|
],
|
|
signUp
|
|
? AuthSwitchRow(
|
|
question: context.l10n.authAlreadyMember,
|
|
action: context.l10n.authLogin,
|
|
onTap: _switchMode,
|
|
)
|
|
: AuthSwitchRow(
|
|
question: context.l10n.authFirstTime,
|
|
action: context.l10n.authSignup,
|
|
onTap: _switchMode,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const _figmaOrder = [
|
|
AuthProviderKind.apple,
|
|
AuthProviderKind.google,
|
|
AuthProviderKind.kakao,
|
|
AuthProviderKind.line,
|
|
];
|
|
|
|
/// 간편 로그인 · 회원가입 버튼 문구 — 시안 「Kakao로 로그인」 · 「Kakao로 가입하기」.
|
|
/// [AuthProviderKind.label](「카카오로 시작하기」)은 설정 · 안내 문구가 쓰므로 따로 둔다.
|
|
/// 제공자별 조사(ko 「LINE으로」)는 arb 의 select 가 맡는다. [l10n] 이 없으면 ko.
|
|
String providerButtonLabel(
|
|
AuthProviderKind kind, {
|
|
bool signUp = false,
|
|
AppLocalizations? l10n,
|
|
}) {
|
|
final t = l10n ?? koL10n;
|
|
return signUp
|
|
? t.authProviderSignup(kind.name)
|
|
: t.authProviderLogin(kind.name);
|
|
}
|
|
|
|
String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
|
|
switch (outcome) {
|
|
AuthSucceeded() => null,
|
|
AuthCancelled() => null,
|
|
AuthFailed(:final code, :final message) => '[$code] $message',
|
|
AuthNotConfigured(:final provider) =>
|
|
(l10n ?? koL10n).authNotConfiguredNotice(provider.shortName),
|
|
};
|
|
|
|
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 {
|
|
const _ProviderButton({
|
|
required this.provider,
|
|
required this.label,
|
|
required this.enabled,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final SocialProvider provider;
|
|
final String label;
|
|
final bool enabled;
|
|
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
|
|
Widget build(BuildContext context) {
|
|
final icon = _icons[provider];
|
|
return FilledButton(
|
|
onPressed: enabled ? onPressed : null,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: AppColors.onCosmic,
|
|
disabledBackgroundColor: Colors.black,
|
|
disabledForegroundColor: AppColors.onCosmicMuted,
|
|
side: const BorderSide(color: Color(0xFF545454)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox.square(
|
|
dimension: 24,
|
|
child: icon == null
|
|
? null
|
|
: ClipRRect(
|
|
borderRadius: BorderRadius.circular(AppRadius.xs),
|
|
child: Image(image: icon, width: 24, height: 24),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AuthNoticeBox extends StatelessWidget {
|
|
const AuthNoticeBox(this.message, {super.key});
|
|
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: const EdgeInsets.all(AppSpace.s4),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.cosmicSurfaceContainer,
|
|
border: Border.all(color: AppColors.cosmicOutline),
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Text(message, style: Theme.of(context).textTheme.bodySmall),
|
|
);
|
|
}
|
|
|
|
/// Firebase 미설정 안내 + 탭 이동 시연 입구.
|
|
class _MockBanner extends StatelessWidget {
|
|
const _MockBanner({required this.onEnter});
|
|
|
|
final VoidCallback onEnter;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final text = Theme.of(context).textTheme;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text('목(mock) 모드', style: text.labelMedium),
|
|
const SizedBox(height: AppSpace.s2),
|
|
Text(FirebaseBoot.reason, style: text.bodySmall),
|
|
const SizedBox(height: AppSpace.s3),
|
|
OutlinedButton(onPressed: onEnter, child: const Text('목 세션으로 탭 둘러보기')),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// QA 계정 전용 이메일 입력 폼 — 숨은 입구로만 열린다.
|
|
///
|
|
/// Figma 「로그인 입력 영역」 명세 —
|
|
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
|
|
/// *로그인 버튼 - 필수값 입력 시 활성화*
|
|
class _EmailForm extends StatelessWidget {
|
|
const _EmailForm({
|
|
required this.formKey,
|
|
required this.email,
|
|
required this.password,
|
|
required this.signUp,
|
|
required this.busy,
|
|
required this.onSubmit,
|
|
required this.onToggleMode,
|
|
required this.onReset,
|
|
});
|
|
|
|
final GlobalKey<FormState> formKey;
|
|
final TextEditingController email;
|
|
final TextEditingController password;
|
|
final bool signUp;
|
|
final bool busy;
|
|
final VoidCallback onSubmit;
|
|
final VoidCallback onToggleMode;
|
|
final VoidCallback onReset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = Theme.of(context).textTheme;
|
|
return Form(
|
|
key: formKey,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(AppSpace.s4),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.cosmicSurfaceContainer,
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(signUp ? '이메일로 가입' : '이메일로 로그인', style: t.titleMedium),
|
|
const SizedBox(height: AppSpace.s3),
|
|
TextFormField(
|
|
controller: email,
|
|
enabled: !busy,
|
|
keyboardType: TextInputType.emailAddress,
|
|
autofillHints: const [AutofillHints.email],
|
|
decoration: const InputDecoration(labelText: '이메일'),
|
|
validator: (value) {
|
|
final v = (value ?? '').trim();
|
|
if (v.isEmpty) return '이메일을 입력해 주세요.';
|
|
// 형식 검사는 최소한만 한다. 지나치게 엄격한 정규식은 유효한
|
|
// 주소를 막는다 — 실제 검증은 가입 확인 메일이 한다.
|
|
if (!v.contains('@') || v.startsWith('@') || v.endsWith('@')) {
|
|
return '이메일 형식이 올바르지 않습니다.';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: AppSpace.s3),
|
|
TextFormField(
|
|
controller: password,
|
|
enabled: !busy,
|
|
obscureText: true,
|
|
autofillHints: const [AutofillHints.password],
|
|
decoration: const InputDecoration(labelText: '비밀번호'),
|
|
validator: (value) {
|
|
final v = value ?? '';
|
|
if (v.isEmpty) return '비밀번호를 입력해 주세요.';
|
|
if (signUp && v.length < EmailFormMetrics.minPasswordLength) {
|
|
return '${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: AppSpace.s4),
|
|
FilledButton(
|
|
onPressed: busy ? null : onSubmit,
|
|
child: Text(signUp ? '가입하기' : '이메일 로그인'),
|
|
),
|
|
const SizedBox(height: AppSpace.s2),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
TextButton(
|
|
onPressed: busy ? null : onToggleMode,
|
|
child: Text(signUp ? '이미 계정이 있어요' : '계정 만들기'),
|
|
),
|
|
if (!signUp)
|
|
TextButton(
|
|
onPressed: busy ? null : onReset,
|
|
child: const Text('비밀번호 재설정'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
|
|
abstract final class EmailFormMetrics {
|
|
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
|
|
/// 6자는 사전 공격에 사실상 무방비다.
|
|
static const minPasswordLength = 8;
|
|
}
|