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

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