로그인을 Figma 2단계(로그인 → 간편 로그인)로 · 회원가입 · 약관 동의 · 가입 완료 화면
This commit is contained in:
@@ -2,20 +2,22 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.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:818` · `510:86330`).
|
||||
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:1905`) → 「간편 로그인」(`173:2183`) 2단계.
|
||||
///
|
||||
/// 첫 화면은 로고 원과 「로그인」 버튼뿐이고, 제공자 선택은 다음 화면([SimpleLoginScreen])이다.
|
||||
/// 「회원가입」은 같은 틀의 가입 화면(`177:9451`)으로 간다.
|
||||
///
|
||||
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
|
||||
/// 같은 회신이 이메일/비밀번호 가입과 X 로그인을 1차 범위에서 뺐으므로 이 화면에
|
||||
/// 이메일 입력 폼으로 가는 **사용자 경로가 없다.** Figma 로그인 화면군의
|
||||
/// 「회원가입」·「비밀번호 재설정」 동선도 같은 이유로 그리지 않는다.
|
||||
/// Figma 의 「Fancake로 로그인」(이메일) · 「X(Twitter)로 로그인」은 같은 회신이 1차 범위에서
|
||||
/// 뺐으므로 그리지 않고, 비밀번호 재설정 동선도 없다.
|
||||
///
|
||||
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
|
||||
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_openQaForm]).
|
||||
///
|
||||
/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다.
|
||||
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_toggleDevPanel]). 목 세션 입구도 같은 곳에 둔다.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key, required this.auth});
|
||||
|
||||
@@ -25,6 +27,9 @@ class LoginScreen extends StatefulWidget {
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
/// 로고 원. 숨은 입구를 찾는 테스트가 이 키로 누른다.
|
||||
const loginLogoKey = Key('login-logo');
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
@@ -33,8 +38,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _notice;
|
||||
bool _busy = false;
|
||||
|
||||
/// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외).
|
||||
bool _qaFormOpen = false;
|
||||
/// 개발용 패널(QA 이메일 폼 · 목 세션)이 열렸는가. 숨은 입구로만 열린다.
|
||||
bool _devOpen = false;
|
||||
|
||||
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
|
||||
bool _signUp = false;
|
||||
@@ -57,7 +62,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_notice = _noticeOf(outcome);
|
||||
_notice = authNotice(outcome);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,21 +81,140 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_busy = false;
|
||||
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
|
||||
_notice = outcome is AuthFailed
|
||||
? _noticeOf(outcome)
|
||||
? authNotice(outcome)
|
||||
: '입력하신 주소로 재설정 안내를 보냈습니다.';
|
||||
});
|
||||
}
|
||||
|
||||
String? _noticeOf(AuthOutcome outcome) => switch (outcome) {
|
||||
AuthSucceeded() => null,
|
||||
AuthCancelled() => null,
|
||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||
AuthNotConfigured(:final provider, :final missing) =>
|
||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
||||
'필요한 항목: ${missing.join(' · ')}',
|
||||
};
|
||||
/// 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
|
||||
///
|
||||
/// 별첨 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>(
|
||||
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 {
|
||||
if (widget.signUp) {
|
||||
final agreed = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => const TermsScreen()));
|
||||
if (agreed != true || !mounted) return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider);
|
||||
if (!mounted) return;
|
||||
@@ -102,11 +226,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _notice = _noticeOf(outcome));
|
||||
setState(() => _notice = authNotice(outcome));
|
||||
}
|
||||
|
||||
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
|
||||
final name = outcome.provider.label.replaceAll('로 시작하기', '');
|
||||
final name = outcome.provider.shortName;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -125,180 +249,243 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
|
||||
///
|
||||
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
|
||||
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
|
||||
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
|
||||
VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null
|
||||
? null
|
||||
: () => setState(() => _qaFormOpen = !_qaFormOpen);
|
||||
void _switchMode() {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
SimpleLoginScreen(auth: widget.auth, signUp: !widget.signUp),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
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(
|
||||
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(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppSpace.s8),
|
||||
// 시안 로그인(173:1905)은 화면 위쪽 가운데에 핑크 원(로고 자리) 하나만 둔다.
|
||||
// 로고 원본은 미확정(Q6)이라 원 안에 워드마크를 텍스트로 넣는다.
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onLongPress: _openQaForm,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
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),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'FANUZU',
|
||||
style: text.titleLarge?.copyWith(
|
||||
color: AppColors.onCosmicAccent,
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w700,
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
Text(
|
||||
signUp
|
||||
? '가장 편한 방법으로\n팬우주에 회원가입 하세요!'
|
||||
: '가장 편한 방법으로\n팬우주에 로그인하세요!',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s6),
|
||||
Text(
|
||||
'당신의 팬 활동을\n하나의 행성으로',
|
||||
style: text.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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),
|
||||
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 {
|
||||
const _ProviderButton({
|
||||
required this.provider,
|
||||
required this.awaitingKeys,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final SocialProvider provider;
|
||||
|
||||
/// 「키 대기」 배지를 붙일지. **버튼을 비활성으로 만들지 않는다** — 눌러야
|
||||
/// 무엇이 없어서 못 쓰는지 안내가 뜬다. 회색 버튼은 사유를 말해 주지 않는다.
|
||||
final bool awaitingKeys;
|
||||
|
||||
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 (fill, label, stroke) = switch (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,
|
||||
),
|
||||
};
|
||||
final icon = _icons[provider];
|
||||
return FilledButton(
|
||||
onPressed: enabled ? onPressed : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fill,
|
||||
foregroundColor: label,
|
||||
disabledBackgroundColor: fill.withValues(alpha: 0.4),
|
||||
side: stroke == null ? null : BorderSide(color: stroke),
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
disabledBackgroundColor: Colors.black,
|
||||
disabledForegroundColor: AppColors.onCosmicMuted,
|
||||
side: const BorderSide(color: Color(0xFF545454)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(provider.label, overflow: TextOverflow.ellipsis),
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: icon == null
|
||||
? null
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
child: Image(image: icon, width: 24, height: 24),
|
||||
),
|
||||
),
|
||||
if (awaitingKeys) ...[
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Text(
|
||||
keyPendingBadge,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: label.withValues(alpha: 0.7),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Notice extends StatelessWidget {
|
||||
const _Notice(this.message);
|
||||
class AuthNoticeBox extends StatelessWidget {
|
||||
const AuthNoticeBox(this.message, {super.key});
|
||||
|
||||
final String message;
|
||||
|
||||
@@ -414,7 +601,7 @@ class _EmailForm extends StatelessWidget {
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
FilledButton(
|
||||
onPressed: busy ? null : onSubmit,
|
||||
child: Text(signUp ? '가입하기' : '로그인'),
|
||||
child: Text(signUp ? '가입하기' : '이메일 로그인'),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Row(
|
||||
@@ -438,10 +625,6 @@ class _EmailForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지.
|
||||
/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다.
|
||||
const String keyPendingBadge = '키 대기';
|
||||
|
||||
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
|
||||
abstract final class EmailFormMetrics {
|
||||
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
|
||||
|
||||
Reference in New Issue
Block a user