import 'package:flutter/material.dart'; import '../../core/firebase/firebase_boot.dart'; import '../../core/theme/tokens.dart'; import 'auth.dart'; import 'auth_repository.dart'; /// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`). /// /// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google · /// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는 /// 버튼 하나로 외부 창을 연다. /// /// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다. class LoginScreen extends StatefulWidget { const LoginScreen({super.key, required this.auth}); final AuthRepository auth; @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _email = TextEditingController(); final _password = TextEditingController(); final _form = GlobalKey(); String? _notice; bool _busy = false; /// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다. bool _emailOpen = false; /// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다. bool _signUp = false; @override void dispose() { _email.dispose(); _password.dispose(); super.dispose(); } Future _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 _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 _signIn(SocialProvider provider) async { setState(() => _busy = true); final outcome = await widget.auth.signIn(provider); if (!mounted) return; setState(() { _busy = false; _notice = _noticeOf(outcome); }); } @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; return Scaffold( body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpace.s5), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppSpace.s6), // 로고 원본은 미확정(Q6)이라 워드마크를 텍스트로 둔다. 확정되면 이미지로 교체. Text('FANUZU', style: text.displayMedium), const SizedBox(height: AppSpace.s4), Text('당신의 팬 활동을\n하나의 행성으로', style: text.displayLarge), const SizedBox(height: AppSpace.s4), Text( '로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.', style: text.bodyMedium?.copyWith(color: AppColors.onCosmicVariant), ), const SizedBox(height: AppSpace.s7), Text('계정으로 시작하기', style: text.titleLarge), 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, enabled: !_busy, 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) ...[ 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), ], ), ), ), ); } } /// 소셜 버튼. 색과 라벨은 각 사 브랜드 가이드 고정값이라 테마를 타지 않는다. class _ProviderButton extends StatelessWidget { const _ProviderButton({ required this.provider, required this.enabled, required this.onPressed, }); final SocialProvider provider; final bool enabled; final VoidCallback onPressed; @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, ), }; 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), ), child: Text(provider.label), ); } } class _Notice extends StatelessWidget { const _Notice(this.message); 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('목 세션으로 탭 둘러보기'), ), ], ); } } /// 이메일 입력 폼. /// /// 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 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; }