From 735bdb76b32386fbd2d2b56e5a7075e17a88986c Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 27 Sep 2026 21:22:02 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=84=20Figma=20?= =?UTF-8?q?2=EB=8B=A8=EA=B3=84(=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=E2=86=92=20?= =?UTF-8?q?=EA=B0=84=ED=8E=B8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8)=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=C2=B7=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=99=84=EB=A3=8C=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/icons/brand_apple.png | Bin 0 -> 986 bytes app/assets/icons/brand_google.png | Bin 0 -> 1961 bytes app/assets/icons/brand_kakao.png | Bin 0 -> 857 bytes app/assets/icons/brand_line.png | Bin 0 -> 7275 bytes app/assets/icons/circle_check.png | Bin 0 -> 1272 bytes app/lib/features/auth/auth.dart | 29 +- app/lib/features/auth/auth_repository.dart | 2 +- .../auth/firebase_account_linker.dart | 10 +- app/lib/features/auth/login_screen.dart | 495 ++++++++++++------ app/lib/features/auth/signup_screens.dart | 283 ++++++++++ app/lib/features/auth/social_providers.dart | 94 ++-- app/test/app_smoke_test.dart | 21 +- app/test/auth_email_test.dart | 10 +- app/test/auth_error_message_test.dart | 3 +- 14 files changed, 729 insertions(+), 218 deletions(-) create mode 100644 app/assets/icons/brand_apple.png create mode 100644 app/assets/icons/brand_google.png create mode 100644 app/assets/icons/brand_kakao.png create mode 100644 app/assets/icons/brand_line.png create mode 100644 app/assets/icons/circle_check.png create mode 100644 app/lib/features/auth/signup_screens.dart diff --git a/app/assets/icons/brand_apple.png b/app/assets/icons/brand_apple.png new file mode 100644 index 0000000000000000000000000000000000000000..38b8a0f4d81036375cac2b5154df72e8867372ff GIT binary patch literal 986 zcmV<0110>4P)+bH1vhw(+^@F^5?yK<_|@Qzno6e=@g)+r>EzCh-k!i!}iFw zW&4Ppf5NQjB_rev3vj`9ORqCRuJFqjY#Tbx1#(1Ui;FSG*x-b`Lq`$M;m0jolXsNJ zkynE8A@ZBS_@&26aaPC?Y7WXz6vzz^@0lS-NEdq^kt@_Zt;_Zz>vhjDmf4CUP`%eW z#D?t)a_4{tt=J}Qdd&bGgX)dvj;$ID20OG&a)I2pEvws0w!(cDcFrEyN`Cngo;dNO z-;wKk-?OCewX^$>{!a?kyY3ssEVLh2rqr<5qvzz9w%`5!$7ma>b2U?nLvlUiC$Xp& zTVl_O;@MDJ0iJZZnyxU#TSB^AP1o=iDJV!&JrrcQZ*kE!;{ZO_Hue_Ci6d!-0ydG^ z?!9&i5t309H!WZjSpp1w)>mF0MD;qvYLe;~3p0i%z5+>tynsFKSez>apdMn*dkWa2 zV*8Fjd}7d7*yNhMr$Qj=A;uoGg}rHg$>Lli0+k23;bn2n^8=A+hu8@AShM%65s6ly z53t7r5}*~RfIUhiKr4{N_){SP=j=gA(zTtf?&u1=Kn-XGYW;K}fFxxx=PXcx1e~)# zmq>tCpx>~E92Z3|PqYG6u*n3GoV!5ZD6$GoD^LlW$PoL92vh>Ogi{5Z{5`rxAU+Gl zB)j95m{8!!s*gY=Y%`;}2B7fyA)l11jAuF3uwDXJvCTPbEH#aNkA37!Y=rYbGLFNh zM!uU>^_jK-ROwhRfn3#=#cjtS?lp}<(jCz!1sy=@AMZ2`Lj+pDz89%Og||SiGQzSK zRoWR$2+b&3_9Cx|CP*OXMHXi?FYgh8bJF~a#EYU&F5ejMFEuu4FNFbChT78CdxR)~ z+}XEXs1|P$1t>zA=SB<47Cc~WS*kRBO0o!?z>i4zCmJ;iA9nJ_Q@Mns5g)D+_`7r>=XP`EG64j7w(%Kgmjl&WH>HJ@t<2< ze`95zGg!gB)QX;C)0QHySyFv}Y~lLZIFrva%K5@?PmfLh00J#Hd~uH1uK)l507*qo IM6N<$g2LaxbpQYW literal 0 HcmV?d00001 diff --git a/app/assets/icons/brand_google.png b/app/assets/icons/brand_google.png new file mode 100644 index 0000000000000000000000000000000000000000..6902e20a6e6de4792b942fc09bc73da422ed01a3 GIT binary patch literal 1961 zcmV;a2UhrrP)_Tc0Dt#mQve>jIGUz}DA<<9H!0lra-B~|J@xGs1xiH81iIbZ6{4UW#G4Jn z8X>qrv4~DTm+GW(uf_6IpxJQ9ul_f86s=$o-v+@0D3Ec12=UfSS7h)2PXt;PW_}om zehhEG52Zo&`NSc&Ey{I)3|FGs8F1=G_DP^nBW?-hnm~rtWaiGG#e|{!D0m(3ZJD?_ z4i0fepxON$!JH0bP(G{43!x<1I(c;j9N<`>J3XC)6!cgL`x;&nnqXERAfe$_ms*5|bek-vSrg0GBuNc`4nZmqmZF5L ze59M=EMFoAw|ml808TrAGdnjqZs2vR{zZlaTt~b`lq)eY1Pd0oJJu;A8NgHO$L`REK1aq z%$mFshha&e*>|=_ax=|Qumd3}O%)DpmEA8(thn9g$*BZ*L{Whjk_`bdOQ#pU-wOAq zT7e4`C#!DgcIr~)q-Z4V)3pd`&|pLFAI8f%L}8Xw&EhbMT2StiG>e;uk9GZC9b~?I z!Ppi;)5JC}+^WpirU4hFAVJgkplNpi>fGFV@2_6)5+e6oNJGl@I1>%;i=@ zqB?P0#j7Mt1;S+1@+bror{&$BpniKgww|nqVV}2D&Si#c0_i%R#Z?Q1I%x$sAe^0g zwcwrw(aIWj=7H78&Dsh*#kihI0)f3ytq>amA-qGj{s;M6)uB^rTDvh#HLwp#p%sBu zZfYCgaJ^OrB^; z;3fe!={=nZVZkoMa||JZsX(SK)`)qPl_%M`h3DbzbjYwE#NR&JAA&N1^(gKnTa(Q1 zvq(jeTr_LTP#>Jk?1otOrL_l@I1_(@Vb1;2^7~Ev7GyvnZ}E#+%OG0KH}{{-*Boj; zd-xcX0p?!78E&w-0fpKN3gp|b%CIIzV4@5@aw!u?q-%5_03`<9>kQ{1*g+SrfEEuF zDa2QPW$wh>in&)2{3^+f1;@fAOF#CV&PKpSu&N89T&TV1bDSo@KeM!H`go?(&z@GC zq||z?J9TK>(?oXvskxvAa2+^7_qv!o(Ohs=Zcz&&-H`6T`&!(FIE**w8(#PTunTRAk^~Tq! zN9P&{XrkuVG%Y9+vKOr|Y<6BIQp24$LCWp{E8vr>O+MbJSlO3*maFkC5__GZ*VNw) zL;dWb!aM0W2KKS_$zURI^4Eo*m2LeyS$W_9R2W+0FUCt+*a1X;GLU6eg2|7Xxt1l^ zswiF?I3GjTaI{FoD^wIH`^;(3>wjC@q-aFsUbDe%K`Wpo$=mvNVx;b@)Aj-?%)`>A zq_B-8H`<>O;7Kl;FA~Be|Q8B9si@R-!!*FH2*q(EJVS&P@w8GYl;qA#1 zm4y8#eW#M;JEDK`kK{_(ZfgCa)EMLlM#qgGIZ_8c^O>5 zlU2{mnluojSo0r*(qW`S#uL^>JY6@>gvt@|7{(F@y|XAH?F^0ew vW&gKhh6*&*T&Kyh#|5|=HEPtTQ6>BjqJqbdGtf)Z00000NkvXXu0mjfo)V23 literal 0 HcmV?d00001 diff --git a/app/assets/icons/brand_kakao.png b/app/assets/icons/brand_kakao.png new file mode 100644 index 0000000000000000000000000000000000000000..5684a0298240a7d90d349a56a954e3f7ed594167 GIT binary patch literal 857 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 FV1m*t>6!ol literal 0 HcmV?d00001 diff --git a/app/assets/icons/circle_check.png b/app/assets/icons/circle_check.png new file mode 100644 index 0000000000000000000000000000000000000000..fea626d5b340d1ec0392b65fec47488a0f42c9b5 GIT binary patch literal 1272 zcmVY(ltkSKTeq66Tf+i~&;CzzV>!0t*zt`&0zb*JahBeU#q9c2Wx9+9>w5PL!4i)9pq)5X@-*0PtA zT*olPZevxWh{184&*!PxZ2oj}yeq+MS&E=o1BJ!KMXk|jTn;;MXbg*(y`UI?n4TLJ z(~dFBt+8mJVgX|MnD5!?7)QZ~4~|=f;zRdw>2$!r80Mq`E0yAhg5>0CP;z1$!y@@V zqj*`@NM~kd=Dl71ZR^qy%dN6P@rCpIvfURJ3zy5~?DX_>RFVrn*jm|Q;i;*qBE?@U z)9tRBcUlDFF|}C9IFbJ6Ow>gQ`&Sf z9-g5PsZ^^~wf6zJK+SK63Afh!0eu!018!ka+27w+jXNG59#ry)S(7f9I|($tF{xo# z#7ZP<%&&F9sZ=U*Vv!F>d1An=bt~&J{Pgt1JtL9X-vxvNay-SMuez}>9D}WnvpKp4wLB6#4^$a z-`?KZxo=lAvYt<2ie+@cXk;{!0Dh+|Zr%b?ymPz#;}&bwrVB=3ODR?f)Ip-n1Yub` za<$&JE*S16yEZ#ibYO0b+H-F^VY-2TBtP3y5x3@6Ixw-IgX_QReduenxEM|Hu~<4l zu@+5zy(O-^V^tves2dv_IsE=5Zs22(RY##or_aJX&49*tVNNl(gD$a}HT4PRkpa;G zp9t8U%{638;$e1yI0M0l=6x2y_xJa8tYkiuLjhv7OeSLqQH$VOt;S*xDdxaak6c$C zU}f&{#Ma7eT{Kp8yYFlTvz2keH;O4v6J8vLIPmN|3u~s%sPGIQ3_z3yeV-h1a*5r8 zVglbQTi5Xz8bX|=Jir_04h{}RpYp))4|^MUx7| zx|(I=dY)FZ**srZShx~7abysdbv>nOq^XB+@nc*_p%CE)3Wc&IH*Rdi^K9BytMwO_ zK80ZJREJWF;tmd$C3Nh6lSh1&8c?%Y;i*jlTDzMNA*ra-i1?Z>&5fC`_O%$@A6I>f z)7&Zs`M>V$?EE4hNPPw1?(VLLxZ(ns6jv8Ds#6H9N9Gj(J6^?09!_w}F3Pcr-D^D9 i$FbQShrl;p8~*{D7YFc-Ft@G%0000 this == AuthProviderKind.email; } @@ -131,12 +135,13 @@ abstract interface class AuthSession { /// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`. /// -/// **로컬 파트만 비교한다.** 명세의 예시가 `test123@naver.com` 과 -/// `test123@gmail.com` 을 같다고 보므로 도메인은 보지 않는다. 전체 문자열을 -/// 비교하면 명세가 (O) 로 적은 사례가 막힌다. -bool sameAccountIdentity(String a, String b) => _localPart(a) == _localPart(b); - -String _localPart(String email) => email.trim().toLowerCase().split('@').first; +/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다(fzu-2z0.5). +/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이 +/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false. +bool sameAccountIdentity(String a, String b) { + final x = a.trim().toLowerCase(); + return x.contains('@') && x == b.trim().toLowerCase(); +} /// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종. /// 이메일은 QA 내부 경로라 목록에 없다. diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index f3b2253..fe4cbdf 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -35,7 +35,7 @@ class AuthRepository extends ChangeNotifier { AuthAccount? get account => _account; bool get isSignedIn => _account != null; - /// **별첨 B v1.4 확정 4종.** 순서가 회신 문구 순서이자 화면 버튼 순서다. + /// **별첨 B v1.4 확정 4종.** 순서는 회신 문구 순서다(화면 버튼은 Figma 순서로 다시 줄 세운다). /// /// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 /// > X 로그인은 제외합니다. diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index 885b3dc..d3b4f11 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -83,7 +83,10 @@ final class FirebaseAccountLinker implements AccountLinker { final result = await user.linkWithCredential(obtained.credential); return AuthSucceeded(_accountOf(result.user, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.link)); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.link), + ); } on Object catch (error) { // SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다. debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}'); @@ -110,7 +113,10 @@ final class FirebaseAccountLinker implements AccountLinker { await user.unlink(id); return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.link)); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.link), + ); } } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 2a43e5c..f714f75 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); } +/// 로고 원. 숨은 입구를 찾는 테스트가 이 키로 누른다. +const loginLogoKey = Key('login-logo'); + class _LoginScreenState extends State { final _email = TextEditingController(); final _password = TextEditingController(); @@ -33,8 +38,8 @@ class _LoginScreenState extends State { 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 { if (!mounted) return; setState(() { _busy = false; - _notice = _noticeOf(outcome); + _notice = authNotice(outcome); }); } @@ -76,21 +81,140 @@ class _LoginScreenState extends State { _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( + 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 createState() => _SimpleLoginScreenState(); +} + +class _SimpleLoginScreenState extends State { + String? _notice; + bool _busy = false; Future _signIn(SocialProvider provider) async { + if (widget.signUp) { + final agreed = await Navigator.of( + context, + ).push(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 { return; } if (!mounted) return; - setState(() => _notice = _noticeOf(outcome)); + setState(() => _notice = authNotice(outcome)); } Future _showNotConfigured(AuthNotConfigured outcome) async { - final name = outcome.provider.label.replaceAll('로 시작하기', ''); + final name = outcome.provider.shortName; await showDialog( context: context, builder: (context) => AlertDialog( @@ -125,180 +249,243 @@ class _LoginScreenState extends State { ); } - /// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**. - /// - /// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다. - /// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은 - /// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다. - VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null - ? null - : () => setState(() => _qaFormOpen = !_qaFormOpen); + void _switchMode() { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + 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자로 올린다 — diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart new file mode 100644 index 0000000..07120fe --- /dev/null +++ b/app/lib/features/auth/signup_screens.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/tokens.dart'; + +/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의). +/// +/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를 +/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다. +/// +/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다. +class TermsScreen extends StatefulWidget { + const TermsScreen({super.key}); + + @override + State createState() => _TermsScreenState(); +} + +enum _Term { + service('서비스 이용약관 (필수)', required: true, body: true), + location('위치기반 서비스 이용약관 (필수)', required: true, body: true), + age('만 14세 이상입니다. (필수)', required: true, body: false), + marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true); + + const _Term(this.label, {required this.required, required this.body}); + + final String label; + final bool required; + final bool body; +} + +const _body = + 'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n' + '추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.'; + +const _stroke = Color(0xFF545454); + +class _TermsScreenState extends State { + final _agreed = <_Term>{}; + + bool get _all => _agreed.length == _Term.values.length; + bool get _ready => + _Term.values.where((t) => t.required).every(_agreed.contains); + + void _toggle(_Term t) => + setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t)); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('이용 약관')), + body: ListView( + padding: const EdgeInsets.all(AppSpace.s4), + children: [ + _Box( + child: _CheckRow( + label: '전체 동의', + checked: _all, + size: 16, + onTap: () => setState( + () => _all ? _agreed.clear() : _agreed.addAll(_Term.values), + ), + ), + ), + for (final t in _Term.values) ...[ + const SizedBox(height: AppSpace.s3), + _Box( + child: t.body + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + // 시안 획은 안쪽이다 — decoration 이면 획 두께만큼 높이가 는다. + foregroundDecoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: _stroke)), + ), + child: _CheckRow( + label: t.label, + checked: _agreed.contains(t), + onTap: () => _toggle(t), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpace.s4), + child: SizedBox( + height: 80, + child: ClipRect( + child: Text( + _body, + overflow: TextOverflow.clip, + style: TextStyle( + fontFamily: AppFonts.display, + fontFamilyFallback: [AppFonts.body], + fontSize: 12, + height: 1.36, + letterSpacing: -0.3, + color: AppColors.onCosmic, + ), + ), + ), + ), + ), + ], + ) + : _CheckRow( + label: t.label, + checked: _agreed.contains(t), + onTap: () => _toggle(t), + ), + ), + ], + const SizedBox(height: AppSpace.s6), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () => Navigator.of(context).pop(false), + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: AppColors.onCosmic, + ), + child: const Text('이전'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: _ready + ? () => Navigator.of(context).pop(true) + : null, + style: FilledButton.styleFrom( + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmicAccent, + disabledBackgroundColor: const Color(0xFF333333), + disabledForegroundColor: const Color(0xFF777777), + ), + child: const Text('완료'), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// 약관 칸 — #545454 획 · 모서리 8 · 좌우 16. +class _Box extends StatelessWidget { + const _Box({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + foregroundDecoration: BoxDecoration( + border: Border.all(color: _stroke), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: child, + ); +} + +/// 체크 행 — circle-check 20 · 간격 16 · ABeeZee. 미동의는 #767676, 동의는 핑크. +class _CheckRow extends StatelessWidget { + const _CheckRow({ + required this.label, + required this.checked, + required this.onTap, + this.size = 14, + }); + + final String label; + final bool checked; + final VoidCallback onTap; + final double size; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpace.s4), + child: Row( + children: [ + ImageIcon( + const AssetImage('assets/icons/circle_check.png'), + size: 20, + color: checked ? AppColors.cosmicAccent : AppColors.onCosmicMuted, + ), + const SizedBox(width: AppSpace.s4), + Expanded( + child: Text( + label, + style: TextStyle( + fontFamily: AppFonts.display, + fontFamilyFallback: const [AppFonts.body], + fontSize: size, + height: size == 16 ? 24 / 16 : 20 / 14, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + ); +} + +/// 회원가입 완료 — Figma `182:29344`. 「시작」은 이 화면을 닫는다. +/// +/// 아직 가입 동선에 걸려 있지 않다. 제공자 인증이 성공하면 라우터 redirect 가 곧장 셸로 +/// 보내므로, 이 화면을 끼우려면 redirect 가 「가입 직후」를 알아야 한다(app_router 공유 변경). +class SignupCompleteScreen extends StatelessWidget { + const SignupCompleteScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.all(20), + child: SizedBox.square( + dimension: 120, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.cosmicAccent, + shape: BoxShape.circle, + ), + ), + ), + ), + const SizedBox(height: AppSpace.s6), + const Text( + '회원가입이 완료되었습니다', + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: 24, + fontWeight: FontWeight.w600, + height: 1, + color: AppColors.onCosmic, + ), + ), + const SizedBox(height: AppSpace.s3), + Text( + '팬덤 활동을 시작할 수 있습니다.\n다양한 활동을 통해 내 팬덤 행성을 키워보세요.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppFonts.display, + fontFamilyFallback: const [AppFonts.body], + fontSize: 14, + height: 24 / 14, + color: const Color(0xFFCDCDCD), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + child: FilledButton( + onPressed: () => Navigator.of(context).maybePop(), + style: FilledButton.styleFrom( + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmicAccent, + ), + child: const Text('시작'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index e77b5ae..32439b1 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -83,7 +83,10 @@ final class GoogleSignInProvider } return AuthFailed('E-AUTH-03', error.description ?? error.code.name); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.signIn), + ); } } @@ -179,7 +182,10 @@ final class AppleSignInProvider debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}'); return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.signIn), + ); } } @@ -278,28 +284,35 @@ final class EmailPasswordProvider implements EmailAuthProvider { } @override - Future signInWithEmail(String email, String password) => - _run(() => fb.FirebaseAuth.instance.signInWithEmailAndPassword( - email: email.trim(), - password: password, - )); + Future signInWithEmail(String email, String password) => _run( + () => fb.FirebaseAuth.instance.signInWithEmailAndPassword( + email: email.trim(), + password: password, + ), + ); @override - Future signUpWithEmail(String email, String password) => - _run(() => fb.FirebaseAuth.instance.createUserWithEmailAndPassword( - email: email.trim(), - password: password, - )); + Future signUpWithEmail(String email, String password) => _run( + () => fb.FirebaseAuth.instance.createUserWithEmailAndPassword( + email: email.trim(), + password: password, + ), + ); @override Future sendPasswordReset(String email) async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { - await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim()); + await fb.FirebaseAuth.instance.sendPasswordResetEmail( + email: email.trim(), + ); return const AuthCancelled(); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.signIn), + ); } } @@ -310,7 +323,10 @@ final class EmailPasswordProvider implements EmailAuthProvider { final result = await action(); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.signIn), + ); } } @@ -337,7 +353,9 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { 'weak-password' => 'E-AUTH-05', 'invalid-email' => 'E-AUTH-06', 'too-many-requests' => 'E-AUTH-07', - // 계정 연결(별첨 B v1.4) 경로에서만 나오는 코드들. + // credential-already-in-use 는 계정 연결 경로에서만 나오지만, + // account-exists-with-different-credential 은 로그인 경로에서도 난다. + // 카탈로그 ID 는 같고 문구만 경로별로 갈린다(authMessage 참고). 'credential-already-in-use' || 'account-exists-with-different-credential' => 'E-AUTH-08', 'provider-already-linked' => 'E-AUTH-09', @@ -369,26 +387,24 @@ enum AuthFlow { /// 그 제공자로 들어가 달라고 안내해야 하고, **계정 연결** 경로에서는 「이 소셜 /// 계정이 다른 FANUZU 계정에 이미 붙어 있다」다. 한 벌로 적으면 로그인에 실패한 /// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다. -String authMessage( - fb.FirebaseAuthException error, { - required AuthFlow flow, -}) => switch (error.code) { - 'email-already-in-use' => '이미 가입된 이메일입니다.', - 'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', - 'invalid-email' => '이메일 형식이 올바르지 않습니다.', - 'too-many-requests' => '잠시 후 다시 시도해 주세요.', - 'account-exists-with-different-credential' => switch (flow) { - AuthFlow.signIn => _otherProviderSignInGuide(error.email), - AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', - }, - // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. - 'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', - 'provider-already-linked' => '이미 연결된 계정입니다.', - 'no-such-provider' => '연결되어 있지 않은 계정입니다.', - 'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.', - 'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', - _ => '이메일 또는 비밀번호를 확인해 주세요.', -}; +String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => + switch (error.code) { + 'email-already-in-use' => '이미 가입된 이메일입니다.', + 'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', + 'invalid-email' => '이메일 형식이 올바르지 않습니다.', + 'too-many-requests' => '잠시 후 다시 시도해 주세요.', + 'account-exists-with-different-credential' => switch (flow) { + AuthFlow.signIn => _otherProviderSignInGuide(error.email), + AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + }, + // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. + 'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + 'provider-already-linked' => '이미 연결된 계정입니다.', + 'no-such-provider' => '연결되어 있지 않은 계정입니다.', + 'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.', + 'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', + _ => '이메일 또는 비밀번호를 확인해 주세요.', + }; /// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내. /// @@ -401,7 +417,11 @@ String authMessage( /// 시도한 자격증명이고, 기존 제공자를 알려면 `fetchSignInMethodsForEmail` 이 /// 필요한데 이메일 열거 방지가 켜지면 빈 목록을 돌려준다. 이름까지 띄워야 하면 /// 서버에서 Admin SDK 로 조회해 내려주는 쪽으로 올린다. +/// +/// 이메일을 조사 앞에 바로 붙이지 않는다. 도메인 끝 글자가 모음인지 자음인지에 +/// 따라 「은/는」이 갈려 `example.co은` 같은 문구가 나간다. 「계정」을 사이에 두면 +/// 조사가 항상 맞는다. String _otherProviderSignInGuide(String? email) => - '${email == null ? '이 계정' : email}은 다른 로그인 수단으로 가입돼 있습니다. ' + '${email ?? '이'} 계정은 다른 로그인 수단으로 가입돼 있습니다. ' '처음 가입에 쓰신 수단으로 로그인한 뒤 MY > 설정 > SNS 연동에서 ' '이 수단을 연결해 주세요.'; diff --git a/app/test/app_smoke_test.dart b/app/test/app_smoke_test.dart index 1c645ff..0a40576 100644 --- a/app/test/app_smoke_test.dart +++ b/app/test/app_smoke_test.dart @@ -7,6 +7,7 @@ import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/core/router/app_router.dart'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; +import 'package:fancake/features/auth/login_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -24,11 +25,13 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(find.text('FANUZU'), findsOneWidget); - // 별첨 B v1.4 확정 4종이 라벨 그대로 있다. + expect(find.byKey(loginLogoKey), findsOneWidget); + // Figma 2단계 — 「로그인」을 누르면 간편 로그인에 별첨 B v1.4 확정 4종이 있다. + await tester.tap(find.widgetWithText(FilledButton, '로그인')); + await tester.pumpAndSettle(); for (final provider in AuthRepository().providers) { expect( - find.text(provider.provider.label), + find.text(providerButtonLabel(provider.provider)), findsOneWidget, reason: provider.provider.name, ); @@ -189,7 +192,12 @@ void main() { reason: '셸에 진입할 수 없어야 한다', ); // 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다. - expect(find.text(SocialProvider.google.label), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '로그인')); + await tester.pumpAndSettle(); + expect( + find.text(providerButtonLabel(SocialProvider.google)), + findsOneWidget, + ); }); testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async { @@ -197,6 +205,9 @@ void main() { FancakeApp(auth: AuthRepository(releaseMode: false)), ); await tester.pumpAndSettle(); + // 목 세션 입구는 QA 폼과 같은 숨은 입구(로고 길게 누르기) 안에 있다. + await tester.longPress(find.byKey(loginLogoKey)); + await tester.pumpAndSettle(); expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget); }); @@ -288,6 +299,8 @@ void main() { Future _enterShell(WidgetTester tester) async { await tester.pumpWidget(FancakeApp(auth: AuthRepository())); await tester.pumpAndSettle(); + await tester.longPress(find.byKey(loginLogoKey)); + await tester.pumpAndSettle(); final enter = find.text('목 세션으로 탭 둘러보기'); await tester.ensureVisible(enter); diff --git a/app/test/auth_email_test.dart b/app/test/auth_email_test.dart index 52637af..9b277a0 100644 --- a/app/test/auth_email_test.dart +++ b/app/test/auth_email_test.dart @@ -166,7 +166,7 @@ void main() { /// 숨은 입구 — 로고를 길게 누른다. 보이는 진입점은 두지 않는다. Future openQaForm(WidgetTester tester) async { - await tester.longPress(find.text('FANUZU')); + await tester.longPress(find.byKey(loginLogoKey)); await tester.pump(); } @@ -187,7 +187,7 @@ void main() { await tester.pumpWidget( MaterialApp(home: LoginScreen(auth: AuthRepository(releaseMode: true))), ); - await tester.longPress(find.text('FANUZU')); + await tester.longPress(find.byKey(loginLogoKey)); await tester.pump(); expect(find.text('이메일'), findsNothing); expect(find.text('비밀번호'), findsNothing); @@ -198,7 +198,7 @@ void main() { await pump(tester, fake); await openQaForm(tester); - await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); + await tapVisible(tester, find.widgetWithText(FilledButton, '이메일 로그인')); await tester.pump(); expect(find.text('이메일을 입력해 주세요.'), findsOneWidget); expect(fake.lastEmail, isNull); @@ -211,7 +211,7 @@ void main() { await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr'); await tester.enterText(find.byType(TextFormField).last, 'password12'); - await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); + await tapVisible(tester, find.widgetWithText(FilledButton, '이메일 로그인')); await tester.pump(); expect(find.text('이메일 형식이 올바르지 않습니다.'), findsOneWidget); expect(fake.lastEmail, isNull); @@ -248,7 +248,7 @@ void main() { 'fan@fanuzu.co.kr', ); await tester.enterText(find.byType(TextFormField).last, 'password12'); - await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); + await tapVisible(tester, find.widgetWithText(FilledButton, '이메일 로그인')); await tester.pump(); await tester.pump(); expect(fake.lastEmail, 'fan@fanuzu.co.kr'); diff --git a/app/test/auth_error_message_test.dart b/app/test/auth_error_message_test.dart index 6862d64..8fb61bc 100644 --- a/app/test/auth_error_message_test.dart +++ b/app/test/auth_error_message_test.dart @@ -18,7 +18,8 @@ void main() { ); // 오류 응답의 이메일 힌트를 그대로 넣어야 어느 계정인지 알 수 있다. - expect(message, contains('fan@gmail.com')); + // 이메일에 조사를 바로 붙이지 않는다 — 도메인 끝 글자에 따라 은/는 이 갈린다. + expect(message, startsWith('fan@gmail.com 계정은')); expect(message, contains('처음 가입에 쓰신 수단으로 로그인')); expect(message, contains('SNS 연동')); // 로그인 경로에서는 「연결된 소셜 계정」이 사실이 아니다.