로그인 · 회원가입 흐름을 서버 계정·약관 동의와 연결
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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로',
|
|
||||||
AuthProviderKind.google: 'Google로',
|
|
||||||
AuthProviderKind.kakao: 'Kakao로',
|
|
||||||
AuthProviderKind.line: 'LINE으로',
|
|
||||||
AuthProviderKind.email: '이메일로',
|
|
||||||
};
|
|
||||||
|
|
||||||
String? authNotice(AuthOutcome outcome) => switch (outcome) {
|
|
||||||
AuthSucceeded() => null,
|
AuthSucceeded() => null,
|
||||||
AuthCancelled() => null,
|
AuthCancelled() => null,
|
||||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||||
AuthNotConfigured(:final provider, :final missing) =>
|
AuthNotConfigured(:final provider, :final missing) =>
|
||||||
'${provider.shortName} 로그인은 아직 준비 중입니다.\n'
|
(l10n ?? koL10n).authNotConfiguredNotice(
|
||||||
'필요한 항목: ${missing.join(' · ')}',
|
provider.shortName,
|
||||||
};
|
missing.join(' · '),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
TextStyle _abz(double size, Color color) => TextStyle(
|
TextStyle _abz(double size, Color color) => TextStyle(
|
||||||
fontFamily: AppFonts.display,
|
fontFamily: AppFonts.display,
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 연동에서 '
|
|
||||||
'이 수단을 연결해 주세요.';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user