가입 수단 5종(이메일·Google·Apple·Kakao·LINE) · 로그인 에뮬레이터 연결 · 최소 지원 Android 10
This commit is contained in:
@@ -5,10 +5,13 @@ import '../../core/theme/tokens.dart';
|
||||
import 'auth.dart';
|
||||
import 'auth_repository.dart';
|
||||
|
||||
/// S-A-LOGIN 로그인.
|
||||
/// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`).
|
||||
///
|
||||
/// 시안: `시안/screens/S-A-LOGIN.html` · 명세: `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md`
|
||||
/// 골격 범위라 4개 버튼과 결과 안내까지만 구현했다. 약관 동의(S-A-TERMS) 이후 동선은 3주차다.
|
||||
/// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google ·
|
||||
/// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는
|
||||
/// 버튼 하나로 외부 창을 연다.
|
||||
///
|
||||
/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key, required this.auth});
|
||||
|
||||
@@ -19,23 +22,77 @@ class LoginScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _form = GlobalKey<FormState>();
|
||||
|
||||
String? _notice;
|
||||
bool _busy = false;
|
||||
|
||||
/// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다.
|
||||
bool _emailOpen = false;
|
||||
|
||||
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
|
||||
bool _signUp = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submitEmail() async {
|
||||
if (!(_form.currentState?.validate() ?? false)) return;
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signInWithEmail(
|
||||
email: _email.text,
|
||||
password: _password.text,
|
||||
signUp: _signUp,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_notice = _noticeOf(outcome);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resetPassword() async {
|
||||
final email = _email.text.trim();
|
||||
if (email.isEmpty) {
|
||||
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
final impl = widget.auth.emailProvider;
|
||||
if (impl == null) return;
|
||||
setState(() => _busy = true);
|
||||
final outcome = await impl.sendPasswordReset(email);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
|
||||
_notice = outcome is AuthFailed
|
||||
? _noticeOf(outcome)
|
||||
: '입력하신 주소로 재설정 안내를 보냈습니다.';
|
||||
});
|
||||
}
|
||||
|
||||
String? _noticeOf(AuthOutcome outcome) => switch (outcome) {
|
||||
AuthSucceeded() => null,
|
||||
AuthCancelled() => null,
|
||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||
AuthNotConfigured(:final provider, :final missing) =>
|
||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
||||
'필요한 항목: ${missing.join(' · ')}',
|
||||
};
|
||||
|
||||
Future<void> _signIn(SocialProvider provider) async {
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_notice = switch (outcome) {
|
||||
AuthSucceeded() => null,
|
||||
AuthCancelled() => null,
|
||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||
AuthNotConfigured(:final provider, :final missing) =>
|
||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
||||
'필요한 항목: ${missing.join(' · ')}',
|
||||
};
|
||||
_notice = _noticeOf(outcome);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,8 +128,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_ProviderButton(
|
||||
provider: p.provider,
|
||||
enabled: !_busy,
|
||||
onPressed: () => _signIn(p.provider),
|
||||
onPressed: p.provider.needsForm
|
||||
? () => setState(() => _emailOpen = !_emailOpen)
|
||||
: () => _signIn(p.provider),
|
||||
),
|
||||
if (p.provider.needsForm && _emailOpen) ...[
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
_EmailForm(
|
||||
formKey: _form,
|
||||
email: _email,
|
||||
password: _password,
|
||||
signUp: _signUp,
|
||||
busy: _busy,
|
||||
onSubmit: _submitEmail,
|
||||
onToggleMode: () => setState(() => _signUp = !_signUp),
|
||||
onReset: _resetPassword,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
if (_notice != null) ...[
|
||||
@@ -110,7 +182,8 @@ class _ProviderButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final (fill, label, stroke) = switch (provider) {
|
||||
SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null),
|
||||
SocialProvider.naver => (BrandColors.naver, BrandColors.naverLabel, null),
|
||||
SocialProvider.line => (BrandColors.line, BrandColors.lineLabel, null),
|
||||
SocialProvider.email => (BrandColors.email, BrandColors.emailLabel, null),
|
||||
SocialProvider.apple => (
|
||||
BrandColors.apple,
|
||||
BrandColors.appleLabel,
|
||||
@@ -176,3 +249,115 @@ class _MockBanner extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 이메일 입력 폼.
|
||||
///
|
||||
/// Figma 「로그인 입력 영역」 명세 —
|
||||
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
|
||||
/// *로그인 버튼 - 필수값 입력 시 활성화*
|
||||
class _EmailForm extends StatelessWidget {
|
||||
const _EmailForm({
|
||||
required this.formKey,
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.signUp,
|
||||
required this.busy,
|
||||
required this.onSubmit,
|
||||
required this.onToggleMode,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final GlobalKey<FormState> formKey;
|
||||
final TextEditingController email;
|
||||
final TextEditingController password;
|
||||
final bool signUp;
|
||||
final bool busy;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onToggleMode;
|
||||
final VoidCallback onReset;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(signUp ? '이메일로 가입' : '이메일로 로그인', style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
TextFormField(
|
||||
controller: email,
|
||||
enabled: !busy,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: const InputDecoration(labelText: '이메일'),
|
||||
validator: (value) {
|
||||
final v = (value ?? '').trim();
|
||||
if (v.isEmpty) return '이메일을 입력해 주세요.';
|
||||
// 형식 검사는 최소한만 한다. 지나치게 엄격한 정규식은 유효한
|
||||
// 주소를 막는다 — 실제 검증은 가입 확인 메일이 한다.
|
||||
if (!v.contains('@') || v.startsWith('@') || v.endsWith('@')) {
|
||||
return '이메일 형식이 올바르지 않습니다.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
TextFormField(
|
||||
controller: password,
|
||||
enabled: !busy,
|
||||
obscureText: true,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: const InputDecoration(labelText: '비밀번호'),
|
||||
validator: (value) {
|
||||
final v = value ?? '';
|
||||
if (v.isEmpty) return '비밀번호를 입력해 주세요.';
|
||||
if (signUp && v.length < EmailFormMetrics.minPasswordLength) {
|
||||
return '${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
FilledButton(
|
||||
onPressed: busy ? null : onSubmit,
|
||||
child: Text(signUp ? '가입하기' : '로그인'),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: busy ? null : onToggleMode,
|
||||
child: Text(signUp ? '이미 계정이 있어요' : '계정 만들기'),
|
||||
),
|
||||
if (!signUp)
|
||||
TextButton(
|
||||
onPressed: busy ? null : onReset,
|
||||
child: const Text('비밀번호 재설정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
|
||||
abstract final class EmailFormMetrics {
|
||||
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
|
||||
/// 6자는 사전 공격에 사실상 무방비다.
|
||||
static const minPasswordLength = 8;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user