로그인 · 회원가입 흐름을 서버 계정·약관 동의와 연결

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 3aca678072
commit de44739e63
6 changed files with 133 additions and 99 deletions
+10 -10
View File
@@ -6,6 +6,8 @@
/// 화면은 이걸 안내 다이얼로그로 보여 준다. /// 화면은 이걸 안내 다이얼로그로 보여 준다.
library; library;
import '../../core/i18n/app_locale.dart';
/// 로그인 수단 — **별첨 B v1.4 확정값**. /// 로그인 수단 — **별첨 B v1.4 확정값**.
/// ///
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 /// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
@@ -16,20 +18,18 @@ library;
/// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지 /// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지
/// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다. /// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다.
enum AuthProviderKind { enum AuthProviderKind {
email('이메일로 시작하기', '이메일'), email,
kakao('카카오로 시작하기', '카카오'), kakao,
line('LINE으로 시작하기', 'LINE'), line,
apple('Apple로 시작하기', 'Apple'), apple,
google('Google로 시작하기', 'Google'); google;
const AuthProviderKind(this.label, this.shortName);
/// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다. /// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다.
final String label; String get label => appL10n.authProviderStart(name);
/// 제공자 이름만 — 설정 「SNS 연동」·안내 문구용. [label] 에서 「로 시작하기」를 /// 제공자 이름만 — 설정 「SNS 연동」·안내 문구용. [label] 에서 「로 시작하기」를
/// 떼어 만들면 「LINE으로」가 「LINE으」로 잘린다(인계 결함). /// 떼어 만들면 「LINE으로」가 「LINE으」로 잘린다(인계 결함).
final String shortName; String get shortName => appL10n.authProviderName(name);
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다. /// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다.
bool get needsForm => this == AuthProviderKind.email; bool get needsForm => this == AuthProviderKind.email;
@@ -194,7 +194,7 @@ final class SampleAccountLinker implements AccountLinker {
@override @override
Future<AuthOutcome> unlink(AuthProviderKind provider) async { Future<AuthOutcome> unlink(AuthProviderKind provider) async {
if (_linked.length <= 1) { if (_linked.length <= 1) {
return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.'); return AuthFailed('E-AUTH-13', appL10n.authLastProvider);
} }
_linked.remove(provider); _linked.remove(provider);
return AuthSucceeded( return AuthSucceeded(
+21 -4
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../../core/firebase/firebase_boot.dart'; import '../../core/firebase/firebase_boot.dart';
import 'auth.dart'; import 'auth.dart';
import 'social_providers.dart'; import 'social_providers.dart';
import '../../core/i18n/app_locale.dart';
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. /// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
class AuthRepository extends ChangeNotifier { class AuthRepository extends ChangeNotifier {
@@ -35,6 +36,17 @@ class AuthRepository extends ChangeNotifier {
AuthAccount? get account => _account; AuthAccount? get account => _account;
bool get isSignedIn => _account != null; bool get isSignedIn => _account != null;
/// 간편 로그인 「회원가입」 모드로 막 인증을 마쳤다. 라우터가 이 값을 보고 가입 완료
/// 화면(`182:29344`)을 한 번 끼운다. [finishSignup] 이 내린다.
bool get justSignedUp => _justSignedUp;
bool _justSignedUp = false;
void finishSignup() {
if (!_justSignedUp) return;
_justSignedUp = false;
notifyListeners();
}
/// **별첨 B v1.4 확정 4종.** 순서는 회신 문구 순서다(화면 버튼은 Figma 순서로 다시 줄 세운다). /// **별첨 B v1.4 확정 4종.** 순서는 회신 문구 순서다(화면 버튼은 Figma 순서로 다시 줄 세운다).
/// ///
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 /// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
@@ -76,7 +88,7 @@ class AuthRepository extends ChangeNotifier {
}) async { }) async {
final impl = qaEmailProvider; final impl = qaEmailProvider;
if (impl == null) { if (impl == null) {
return const AuthFailed('E-AUTH-03', '이메일 로그인을 쓸 수 없습니다.'); return AuthFailed('E-AUTH-03', appL10n.authEmailUnavailable);
} }
late final AuthOutcome outcome; late final AuthOutcome outcome;
try { try {
@@ -85,13 +97,16 @@ class AuthRepository extends ChangeNotifier {
: await impl.signInWithEmail(email, password); : await impl.signInWithEmail(email, password);
} on Object catch (error) { } on Object catch (error) {
debugPrint('[AuthRepository] email sign-in failed: ${error.runtimeType}'); debugPrint('[AuthRepository] email sign-in failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-01', '이메일 또는 비밀번호를 확인해 주세요.'); return AuthFailed('E-AUTH-01', appL10n.authCheckCredentials);
} }
if (outcome is AuthSucceeded) _replaceAccount(outcome.account); if (outcome is AuthSucceeded) _replaceAccount(outcome.account);
return outcome; return outcome;
} }
Future<AuthOutcome> signIn(SocialProvider provider) async { Future<AuthOutcome> signIn(
SocialProvider provider, {
bool signUp = false,
}) async {
final impl = providers.firstWhere( final impl = providers.firstWhere(
(p) => p.provider == provider, (p) => p.provider == provider,
orElse: () => throw ArgumentError.value( orElse: () => throw ArgumentError.value(
@@ -109,9 +124,10 @@ class AuthRepository extends ChangeNotifier {
debugPrint( debugPrint(
'[AuthRepository] ${provider.name} sign-in failed: ${error.runtimeType}', '[AuthRepository] ${provider.name} sign-in failed: ${error.runtimeType}',
); );
outcome = const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); outcome = AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} }
if (outcome is AuthSucceeded) { if (outcome is AuthSucceeded) {
_justSignedUp = signUp;
_replaceAccount(outcome.account); _replaceAccount(outcome.account);
} }
return outcome; return outcome;
@@ -165,6 +181,7 @@ class AuthRepository extends ChangeNotifier {
} }
void _replaceAccount(AuthAccount? next) { void _replaceAccount(AuthAccount? next) {
if (next == null) _justSignedUp = false;
if (_sameAccount(_account, next)) return; if (_sameAccount(_account, next)) return;
_account = next; _account = next;
notifyListeners(); notifyListeners();
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'auth.dart'; import 'auth.dart';
import 'social_providers.dart'; import 'social_providers.dart';
import '../../core/i18n/app_locale.dart';
/// 하나의 FANUZU UID 에 소셜 Provider 를 붙이고 떼는 Firebase 어댑터. /// 하나의 FANUZU UID 에 소셜 Provider 를 붙이고 떼는 Firebase 어댑터.
/// ///
@@ -47,7 +48,7 @@ final class FirebaseAccountLinker implements AccountLinker {
Future<AuthOutcome> link(AuthProviderKind provider) async { Future<AuthOutcome> link(AuthProviderKind provider) async {
final user = _firebaseAuth.currentUser; final user = _firebaseAuth.currentUser;
if (user == null) { if (user == null) {
return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.'); return AuthFailed('E-AUTH-03', appL10n.authLoginRequired);
} }
final impl = _implOf(provider); final impl = _implOf(provider);
if (impl == null) { if (impl == null) {
@@ -77,7 +78,7 @@ final class FirebaseAccountLinker implements AccountLinker {
if (mine != null && if (mine != null &&
theirs != null && theirs != null &&
!sameAccountIdentity(mine, theirs)) { !sameAccountIdentity(mine, theirs)) {
return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.'); return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail);
} }
final result = await user.linkWithCredential(obtained.credential); final result = await user.linkWithCredential(obtained.credential);
@@ -90,7 +91,7 @@ final class FirebaseAccountLinker implements AccountLinker {
} on Object catch (error) { } on Object catch (error) {
// SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다. // SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다.
debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}'); debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-03', '계정 연결에 실패했습니다.'); return AuthFailed('E-AUTH-03', appL10n.authLinkFailed);
} }
} }
@@ -98,16 +99,16 @@ final class FirebaseAccountLinker implements AccountLinker {
Future<AuthOutcome> unlink(AuthProviderKind provider) async { Future<AuthOutcome> unlink(AuthProviderKind provider) async {
final user = _firebaseAuth.currentUser; final user = _firebaseAuth.currentUser;
if (user == null) { if (user == null) {
return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.'); return AuthFailed('E-AUTH-03', appL10n.authLoginRequired);
} }
// **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에 // **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에
// 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다. // 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다.
if (linkedAccounts.length <= 1) { if (linkedAccounts.length <= 1) {
return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.'); return AuthFailed('E-AUTH-13', appL10n.authLastProvider);
} }
final id = providerIds[provider]; final id = providerIds[provider];
if (id == null) { if (id == null) {
return const AuthFailed('E-AUTH-10', '연결되어 있지 않은 계정입니다.'); return AuthFailed('E-AUTH-10', appL10n.authNotLinked);
} }
try { try {
await user.unlink(id); await user.unlink(id);
+49 -39
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/firebase/firebase_boot.dart'; import '../../core/firebase/firebase_boot.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import 'auth.dart'; import 'auth.dart';
@@ -62,14 +63,14 @@ class _LoginScreenState extends State<LoginScreen> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_busy = false; _busy = false;
_notice = authNotice(outcome); _notice = authNotice(outcome, context.l10n);
}); });
} }
Future<void> _resetPassword() async { Future<void> _resetPassword() async {
final email = _email.text.trim(); final email = _email.text.trim();
if (email.isEmpty) { if (email.isEmpty) {
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.'); setState(() => _notice = context.l10n.authResetNeedEmail);
return; return;
} }
final impl = widget.auth.qaEmailProvider; final impl = widget.auth.qaEmailProvider;
@@ -81,8 +82,8 @@ class _LoginScreenState extends State<LoginScreen> {
_busy = false; _busy = false;
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다. // 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
_notice = outcome is AuthFailed _notice = outcome is AuthFailed
? authNotice(outcome) ? authNotice(outcome, context.l10n)
: '입력하신 주소로 재설정 안내를 보냈습니다.'; : context.l10n.authResetSent;
}); });
} }
@@ -171,12 +172,12 @@ class _LoginScreenState extends State<LoginScreen> {
backgroundColor: AppColors.cosmicAccent, backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent, foregroundColor: AppColors.onCosmicAccent,
), ),
child: const Text('로그인'), child: Text(context.l10n.authLogin),
), ),
const SizedBox(height: AppSpace.s4), const SizedBox(height: AppSpace.s4),
AuthSwitchRow( AuthSwitchRow(
question: '팬우주는 처음이세요?', question: context.l10n.authFirstTime,
action: '회원가입', action: context.l10n.authSignup,
onTap: () => _open(signUp: true), onTap: () => _open(signUp: true),
), ),
], ],
@@ -216,7 +217,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
if (agreed != true || !mounted) return; if (agreed != true || !mounted) return;
} }
setState(() => _busy = true); setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider); final outcome = await widget.auth.signIn(provider, signUp: widget.signUp);
if (!mounted) return; if (!mounted) return;
setState(() => _busy = false); setState(() => _busy = false);
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면 // 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
@@ -226,7 +227,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
return; return;
} }
if (!mounted) return; if (!mounted) return;
setState(() => _notice = authNotice(outcome)); setState(() => _notice = authNotice(outcome, context.l10n));
} }
Future<void> _showNotConfigured(AuthNotConfigured outcome) async { Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
@@ -234,15 +235,17 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text('$name 로그인 준비 중'), title: Text(context.l10n.authNotConfiguredTitle(name)),
content: Text( content: Text(
'$name 로그인은 발주사 키가 들어오면 열립니다.\n\n' context.l10n.authNotConfiguredBody(
'필요한 항목\n· ${outcome.missing.join('\n· ')}', name,
outcome.missing.join('\n· '),
),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('확인'), child: Text(context.l10n.commonConfirm),
), ),
], ],
), ),
@@ -294,8 +297,8 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
const SizedBox(height: AppSpace.s5), const SizedBox(height: AppSpace.s5),
Text( Text(
signUp signUp
? '가장 편한 방법으로\n팬우주에 회원가입 하세요!' ? context.l10n.authSignupHeadline
: '가장 편한 방법으로\n팬우주에 로그인하세요!', : context.l10n.authLoginHeadline,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
@@ -317,7 +320,11 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
for (final p in providers) ...[ for (final p in providers) ...[
_ProviderButton( _ProviderButton(
provider: p.provider, provider: p.provider,
label: providerButtonLabel(p.provider, signUp: signUp), label: providerButtonLabel(
p.provider,
signUp: signUp,
l10n: context.l10n,
),
enabled: !_busy, enabled: !_busy,
onPressed: () => _signIn(p.provider), onPressed: () => _signIn(p.provider),
), ),
@@ -329,13 +336,13 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
], ],
signUp signUp
? AuthSwitchRow( ? AuthSwitchRow(
question: '이미 가입하셨나요?', question: context.l10n.authAlreadyMember,
action: '로그인', action: context.l10n.authLogin,
onTap: _switchMode, onTap: _switchMode,
) )
: AuthSwitchRow( : AuthSwitchRow(
question: '팬우주는 처음이세요?', question: context.l10n.authFirstTime,
action: '회원가입', action: context.l10n.authSignup,
onTap: _switchMode, onTap: _switchMode,
), ),
], ],
@@ -357,26 +364,29 @@ const _figmaOrder = [
/// 간편 로그인 · 회원가입 버튼 문구 — 시안 「Kakao로 로그인」 · 「Kakao로 가입하기」. /// 간편 로그인 · 회원가입 버튼 문구 — 시안 「Kakao로 로그인」 · 「Kakao로 가입하기」.
/// [AuthProviderKind.label](「카카오로 시작하기」)은 설정 · 안내 문구가 쓰므로 따로 둔다. /// [AuthProviderKind.label](「카카오로 시작하기」)은 설정 · 안내 문구가 쓰므로 따로 둔다.
String providerButtonLabel(AuthProviderKind kind, {bool signUp = false}) => /// 제공자별 조사(ko 「LINE으로」)는 arb 의 select 가 맡는다. [l10n] 이 없으면 ko.
'${_names[kind]} ${signUp ? '가입하기' : '로그인'}'; String providerButtonLabel(
AuthProviderKind kind, {
bool signUp = false,
AppLocalizations? l10n,
}) {
final t = l10n ?? koL10n;
return signUp
? t.authProviderSignup(kind.name)
: t.authProviderLogin(kind.name);
}
/// 시안 버튼 문구의 제공자 표기. LINE 은 조사가 「으로」다. String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
const _names = { switch (outcome) {
AuthProviderKind.apple: 'Apple로', AuthSucceeded() => null,
AuthProviderKind.google: 'Google로', AuthCancelled() => null,
AuthProviderKind.kakao: 'Kakao로', AuthFailed(:final code, :final message) => '[$code] $message',
AuthProviderKind.line: 'LINE으로', AuthNotConfigured(:final provider, :final missing) =>
AuthProviderKind.email: '이메일로', (l10n ?? koL10n).authNotConfiguredNotice(
}; provider.shortName,
missing.join(' · '),
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( TextStyle _abz(double size, Color color) => TextStyle(
fontFamily: AppFonts.display, fontFamily: AppFonts.display,
+29 -22
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의). /// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
@@ -16,16 +17,22 @@ class TermsScreen extends StatefulWidget {
} }
enum _Term { enum _Term {
service('서비스 이용약관 (필수)', required: true, body: true), service(required: true, body: true),
location('위치기반 서비스 이용약관 (필수)', required: true, body: true), location(required: true, body: true),
age('만 14세 이상입니다. (필수)', required: true, body: false), age(required: true, body: false),
marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true); marketing(required: false, body: true);
const _Term(this.label, {required this.required, required this.body}); const _Term({required this.required, required this.body});
final String label;
final bool required; final bool required;
final bool body; final bool body;
String label(AppLocalizations l10n) => switch (this) {
service => l10n.termsService,
location => l10n.termsLocation,
age => l10n.termsAge,
marketing => l10n.termsMarketing,
};
} }
const _body = const _body =
@@ -47,13 +54,13 @@ class _TermsScreenState extends State<TermsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('이용 약관')), appBar: AppBar(title: Text(context.l10n.termsTitle)),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
children: [ children: [
_Box( _Box(
child: _CheckRow( child: _CheckRow(
label: '전체 동의', label: context.l10n.termsAgreeAll,
checked: _all, checked: _all,
size: 16, size: 16,
onTap: () => setState( onTap: () => setState(
@@ -74,7 +81,7 @@ class _TermsScreenState extends State<TermsScreen> {
border: Border(bottom: BorderSide(color: _stroke)), border: Border(bottom: BorderSide(color: _stroke)),
), ),
child: _CheckRow( child: _CheckRow(
label: t.label, label: t.label(context.l10n),
checked: _agreed.contains(t), checked: _agreed.contains(t),
onTap: () => _toggle(t), onTap: () => _toggle(t),
), ),
@@ -102,7 +109,7 @@ class _TermsScreenState extends State<TermsScreen> {
], ],
) )
: _CheckRow( : _CheckRow(
label: t.label, label: t.label(context.l10n),
checked: _agreed.contains(t), checked: _agreed.contains(t),
onTap: () => _toggle(t), onTap: () => _toggle(t),
), ),
@@ -118,7 +125,7 @@ class _TermsScreenState extends State<TermsScreen> {
backgroundColor: Colors.black, backgroundColor: Colors.black,
foregroundColor: AppColors.onCosmic, foregroundColor: AppColors.onCosmic,
), ),
child: const Text('이전'), child: Text(context.l10n.commonPrev),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -133,7 +140,7 @@ class _TermsScreenState extends State<TermsScreen> {
disabledBackgroundColor: const Color(0xFF333333), disabledBackgroundColor: const Color(0xFF333333),
disabledForegroundColor: const Color(0xFF777777), disabledForegroundColor: const Color(0xFF777777),
), ),
child: const Text('완료'), child: Text(context.l10n.commonDone),
), ),
), ),
], ],
@@ -206,12 +213,12 @@ class _CheckRow extends StatelessWidget {
); );
} }
/// 회원가입 완료 — Figma `182:29344`. 「시작」은 이 화면을 닫는다. /// 회원가입 완료 — Figma `182:29344`. 가입 직후 라우터 redirect 가 한 번 띄우고
/// /// ([AuthRepository.justSignedUp]), 「시작」은 [onStart] 로 셸에 들어간다.
/// 아직 가입 동선에 걸려 있지 않다. 제공자 인증이 성공하면 라우터 redirect 가 곧장 셸로
/// 보내므로, 이 화면을 끼우려면 redirect 가 「가입 직후」를 알아야 한다(app_router 공유 변경).
class SignupCompleteScreen extends StatelessWidget { class SignupCompleteScreen extends StatelessWidget {
const SignupCompleteScreen({super.key}); const SignupCompleteScreen({super.key, this.onStart});
final VoidCallback? onStart;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -238,8 +245,8 @@ class SignupCompleteScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: AppSpace.s6), const SizedBox(height: AppSpace.s6),
const Text( Text(
'회원가입이 완료되었습니다', context.l10n.signupDoneTitle,
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
fontSize: 24, fontSize: 24,
@@ -250,7 +257,7 @@ class SignupCompleteScreen extends StatelessWidget {
), ),
const SizedBox(height: AppSpace.s3), const SizedBox(height: AppSpace.s3),
Text( Text(
'팬덤 활동을 시작할 수 있습니다.\n다양한 활동을 통해 내 팬덤 행성을 키워보세요.', context.l10n.signupDoneBody,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.display, fontFamily: AppFonts.display,
@@ -267,12 +274,12 @@ class SignupCompleteScreen extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: FilledButton( child: FilledButton(
onPressed: () => Navigator.of(context).maybePop(), onPressed: onStart ?? () => Navigator.of(context).maybePop(),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent, backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent, foregroundColor: AppColors.onCosmicAccent,
), ),
child: const Text('시작'), child: Text(context.l10n.commonStart),
), ),
), ),
], ],
+17 -18
View File
@@ -8,6 +8,7 @@ import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
import '../../core/firebase/firebase_boot.dart'; import '../../core/firebase/firebase_boot.dart';
import 'auth.dart'; import 'auth.dart';
import '../../core/i18n/app_locale.dart';
/// Firebase 자격증명을 만들 수 있는 제공자. /// Firebase 자격증명을 만들 수 있는 제공자.
/// ///
@@ -71,7 +72,7 @@ final class GoogleSignInProvider
try { try {
final obtained = await obtainCredential(); final obtained = await obtainCredential();
if (obtained == null) { if (obtained == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} }
final result = await fb.FirebaseAuth.instance.signInWithCredential( final result = await fb.FirebaseAuth.instance.signInWithCredential(
obtained.credential, obtained.credential,
@@ -162,12 +163,12 @@ final class AppleSignInProvider
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
if (!await apple.SignInWithApple.isAvailable()) { if (!await apple.SignInWithApple.isAvailable()) {
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.'); return AuthFailed('E-AUTH-03', appL10n.authAppleUnavailable);
} }
try { try {
final obtained = await obtainCredential(); final obtained = await obtainCredential();
if (obtained == null) { if (obtained == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} }
final result = await fb.FirebaseAuth.instance.signInWithCredential( final result = await fb.FirebaseAuth.instance.signInWithCredential(
obtained.credential, obtained.credential,
@@ -180,7 +181,7 @@ final class AppleSignInProvider
return AuthFailed('E-AUTH-03', error.message); return AuthFailed('E-AUTH-03', error.message);
} on apple.SignInWithAppleException catch (error) { } on apple.SignInWithAppleException catch (error) {
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}'); debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on fb.FirebaseAuthException catch (error) { } on fb.FirebaseAuthException catch (error) {
return AuthFailed( return AuthFailed(
authCatalogCode(error), authCatalogCode(error),
@@ -389,21 +390,21 @@ enum AuthFlow {
/// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다. /// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다.
String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
switch (error.code) { switch (error.code) {
'email-already-in-use' => '이미 가입된 이메일입니다.', 'email-already-in-use' => appL10n.authEmailInUse,
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', 'weak-password' => appL10n.authWeakPassword,
'invalid-email' => '이메일 형식이 올바르지 않습니다.', 'invalid-email' => appL10n.authInvalidEmail,
'too-many-requests' => '잠시 후 다시 시도해 주세요.', 'too-many-requests' => appL10n.commonTryLater,
'account-exists-with-different-credential' => switch (flow) { 'account-exists-with-different-credential' => switch (flow) {
AuthFlow.signIn => _otherProviderSignInGuide(error.email), AuthFlow.signIn => _otherProviderSignInGuide(error.email),
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', AuthFlow.link => appL10n.authCredentialInUse,
}, },
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', 'credential-already-in-use' => appL10n.authCredentialInUse,
'provider-already-linked' => '이미 연결된 계정입니다.', 'provider-already-linked' => appL10n.authAlreadyLinked,
'no-such-provider' => '연결되어 있지 않은 계정입니다.', 'no-such-provider' => appL10n.authNotLinked,
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.', 'operation-not-allowed' => appL10n.authMethodUnavailable,
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', 'requires-recent-login' => appL10n.authRecentLogin,
_ => '이메일 또는 비밀번호를 확인해 주세요.', _ => appL10n.authCheckCredentials,
}; };
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내. /// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
@@ -422,6 +423,4 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
/// 따라 「은/는」이 갈려 `example.co은` 같은 문구가 나간다. 「계정」을 사이에 두면 /// 따라 「은/는」이 갈려 `example.co은` 같은 문구가 나간다. 「계정」을 사이에 두면
/// 조사가 항상 맞는다. /// 조사가 항상 맞는다.
String _otherProviderSignInGuide(String? email) => String _otherProviderSignInGuide(String? email) =>
'${email ?? '이'} 계정은 다른 로그인 수단으로 가입돼 있습니다. ' appL10n.authOtherProviderGuide(email ?? appL10n.authThisAccount);
'처음 가입에 쓰신 수단으로 로그인한 뒤 MY > 설정 > SNS 연동에서 '
'이 수단을 연결해 주세요.';