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 '../../core/ui/sheet_route.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 createState() => _LoginScreenState(); } /// 로고 원. 숨은 입구를 찾는 테스트가 이 키로 누른다. const loginLogoKey = Key('login-logo'); class _LoginScreenState extends State { final _email = TextEditingController(); final _password = TextEditingController(); final _form = GlobalKey(); String? _notice; bool _busy = false; /// 개발용 패널(QA 이메일 폼 · 목 세션)이 열렸는가. 숨은 입구로만 열린다. bool _devOpen = 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 = authNotice(outcome, context.l10n); }); } Future _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( // 명세 `510:86374` 「닫기 — 아래 방향 Move Out 으로 이전 화면」 — 아래에서 올라오는 전체 화면. // fullscreenDialog 만으로는 Android 에서 페이드 · 확대로 열렸다(10/6 실기기 E-5). SheetRoute( 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 createState() => _SimpleLoginScreenState(); } class _SimpleLoginScreenState extends State { String? _notice; bool _busy = false; Future _signIn(SocialProvider provider) async { if (_busy) return; if (widget.signUp) { final agreed = await Navigator.of(context).push>( MaterialPageRoute(builder: (_) => const TermsScreen()), ); if (agreed == null || !mounted) return; // 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다). widget.auth.pendingConsent = agreed; } // 제공자 화면에서 돌아온 뒤에도 서버 토큰 교환 · 로그인에 몇 초 걸린다(Kakao · LINE). // 끝날 때까지 화면 전체를 막고 진행 표시를 띄운다 — 다시 누르지 못하게. 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 _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( SheetRoute( 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), ]; final scaffold = 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, ), ], ), ), ], ), ), ); return PopScope( canPop: !_busy, child: Stack(children: [scaffold, if (_busy) const BlockingProgress()]), ); } } /// 진행 중 화면 전체를 막는 반투명 막 + 가운데 진행 표시. class BlockingProgress extends StatelessWidget { const BlockingProgress({super.key}); @override Widget build(BuildContext context) => const Stack( children: [ ModalBarrier(dismissible: false, color: Color(0x8A000000)), Center(child: CircularProgressIndicator()), ], ); } 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); } /// 로그인 결과 안내. 취소 · 중단도 안내한다 — 1차 검수 R01 「취소 · 실패 시 로그인 성공으로 처리하지 않음 · /// 오류 안내와 정상 재시도 가능」. 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다. String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) => switch (outcome) { AuthSucceeded() => null, AuthCancelled() => (l10n ?? koL10n).authSocialCancelled, 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 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; }