diff --git a/app/assets/icons/brand_apple.png b/app/assets/icons/brand_apple.png new file mode 100644 index 0000000..38b8a0f Binary files /dev/null and b/app/assets/icons/brand_apple.png differ diff --git a/app/assets/icons/brand_google.png b/app/assets/icons/brand_google.png new file mode 100644 index 0000000..6902e20 Binary files /dev/null and b/app/assets/icons/brand_google.png differ diff --git a/app/assets/icons/brand_kakao.png b/app/assets/icons/brand_kakao.png new file mode 100644 index 0000000..5684a02 Binary files /dev/null and b/app/assets/icons/brand_kakao.png differ diff --git a/app/assets/icons/brand_line.png b/app/assets/icons/brand_line.png new file mode 100644 index 0000000..5e083e5 Binary files /dev/null and b/app/assets/icons/brand_line.png differ diff --git a/app/assets/icons/circle_check.png b/app/assets/icons/circle_check.png new file mode 100644 index 0000000..fea626d Binary files /dev/null and b/app/assets/icons/circle_check.png differ diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index 844d9f4..99ff8af 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -16,17 +16,21 @@ library; /// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지 /// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다. enum AuthProviderKind { - email('이메일로 시작하기'), - kakao('카카오로 시작하기'), - line('LINE으로 시작하기'), - apple('Apple로 시작하기'), - google('Google로 시작하기'); + email('이메일로 시작하기', '이메일'), + kakao('카카오로 시작하기', '카카오'), + line('LINE으로 시작하기', 'LINE'), + apple('Apple로 시작하기', 'Apple'), + google('Google로 시작하기', 'Google'); - const AuthProviderKind(this.label); + const AuthProviderKind(this.label, this.shortName); /// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다. final String label; + /// 제공자 이름만 — 설정 「SNS 연동」·안내 문구용. [label] 에서 「로 시작하기」를 + /// 떼어 만들면 「LINE으로」가 「LINE으」로 잘린다(인계 결함). + final String shortName; + /// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다. bool get needsForm => 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 연동')); // 로그인 경로에서는 「연결된 소셜 계정」이 사실이 아니다.