로그인 4종(Google·Apple·Kakao·LINE) 전환 · 이메일 가입 비노출 · 계정 연결 · STATION 탭 비노출 플래그
This commit is contained in:
@@ -5,11 +5,15 @@ import '../../core/theme/tokens.dart';
|
||||
import 'auth.dart';
|
||||
import 'auth_repository.dart';
|
||||
|
||||
/// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`).
|
||||
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:818` · `510:86330`).
|
||||
///
|
||||
/// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google ·
|
||||
/// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는
|
||||
/// 버튼 하나로 외부 창을 연다.
|
||||
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
|
||||
/// 같은 회신이 이메일/비밀번호 가입과 X 로그인을 1차 범위에서 뺐으므로 이 화면에
|
||||
/// 이메일 입력 폼으로 가는 **사용자 경로가 없다.** Figma 로그인 화면군의
|
||||
/// 「회원가입」·「비밀번호 재설정」 동선도 같은 이유로 그리지 않는다.
|
||||
///
|
||||
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
|
||||
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_openQaForm]).
|
||||
///
|
||||
/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
@@ -29,8 +33,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _notice;
|
||||
bool _busy = false;
|
||||
|
||||
/// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다.
|
||||
bool _emailOpen = false;
|
||||
/// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외).
|
||||
bool _qaFormOpen = false;
|
||||
|
||||
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
|
||||
bool _signUp = false;
|
||||
@@ -63,7 +67,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
final impl = widget.auth.emailProvider;
|
||||
final impl = widget.auth.qaEmailProvider;
|
||||
if (impl == null) return;
|
||||
setState(() => _busy = true);
|
||||
final outcome = await impl.sendPasswordReset(email);
|
||||
@@ -90,12 +94,46 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_notice = _noticeOf(outcome);
|
||||
});
|
||||
setState(() => _busy = false);
|
||||
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
|
||||
// 사용자는 앱이 멈춘 줄 안다.
|
||||
if (outcome is AuthNotConfigured) {
|
||||
await _showNotConfigured(outcome);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _notice = _noticeOf(outcome));
|
||||
}
|
||||
|
||||
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
|
||||
final name = outcome.provider.label.replaceAll('로 시작하기', '');
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('$name 로그인 준비 중'),
|
||||
content: Text(
|
||||
'$name 로그인은 발주사 키가 들어오면 열립니다.\n\n'
|
||||
'필요한 항목\n· ${outcome.missing.join('\n· ')}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
|
||||
///
|
||||
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
|
||||
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
|
||||
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
|
||||
VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null
|
||||
? null
|
||||
: () => setState(() => _qaFormOpen = !_qaFormOpen);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
@@ -110,20 +148,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// 시안 로그인(173:1905)은 화면 위쪽 가운데에 핑크 원(로고 자리) 하나만 둔다.
|
||||
// 로고 원본은 미확정(Q6)이라 원 안에 워드마크를 텍스트로 넣는다.
|
||||
Center(
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'FANUZU',
|
||||
style: text.titleLarge?.copyWith(
|
||||
color: AppColors.onCosmicAccent,
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w700,
|
||||
child: GestureDetector(
|
||||
onLongPress: _openQaForm,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'FANUZU',
|
||||
style: text.titleLarge?.copyWith(
|
||||
color: AppColors.onCosmicAccent,
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -131,44 +172,46 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
const SizedBox(height: AppSpace.s6),
|
||||
Text(
|
||||
'당신의 팬 활동을\n하나의 행성으로',
|
||||
style: text.headlineSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
style: text.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Text(
|
||||
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
|
||||
style: text.bodySmall?.copyWith(color: AppColors.onCosmicVariant),
|
||||
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,
|
||||
),
|
||||
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: p.provider.needsForm
|
||||
? () => setState(() => _emailOpen = !_emailOpen)
|
||||
: () => _signIn(p.provider),
|
||||
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,
|
||||
),
|
||||
if (p.provider.needsForm && _emailOpen) ...[
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
_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) ...[
|
||||
@@ -194,11 +237,17 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
class _ProviderButton extends StatelessWidget {
|
||||
const _ProviderButton({
|
||||
required this.provider,
|
||||
required this.awaitingKeys,
|
||||
required this.enabled,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final SocialProvider provider;
|
||||
|
||||
/// 「키 대기」 배지를 붙일지. **버튼을 비활성으로 만들지 않는다** — 눌러야
|
||||
/// 무엇이 없어서 못 쓰는지 안내가 뜬다. 회색 버튼은 사유를 말해 주지 않는다.
|
||||
final bool awaitingKeys;
|
||||
|
||||
final bool enabled;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@@ -227,7 +276,23 @@ class _ProviderButton extends StatelessWidget {
|
||||
disabledBackgroundColor: fill.withValues(alpha: 0.4),
|
||||
side: stroke == null ? null : BorderSide(color: stroke),
|
||||
),
|
||||
child: Text(provider.label),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(provider.label, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (awaitingKeys) ...[
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Text(
|
||||
keyPendingBadge,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: label.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -265,17 +330,13 @@ class _MockBanner extends StatelessWidget {
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(FirebaseBoot.reason, style: text.bodySmall),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
OutlinedButton(
|
||||
onPressed: onEnter,
|
||||
child: const Text('목 세션으로 탭 둘러보기'),
|
||||
),
|
||||
OutlinedButton(onPressed: onEnter, child: const Text('목 세션으로 탭 둘러보기')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 이메일 입력 폼.
|
||||
/// QA 계정 전용 이메일 입력 폼 — 숨은 입구로만 열린다.
|
||||
///
|
||||
/// Figma 「로그인 입력 영역」 명세 —
|
||||
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
|
||||
@@ -301,7 +362,6 @@ class _EmailForm extends StatelessWidget {
|
||||
final VoidCallback onToggleMode;
|
||||
final VoidCallback onReset;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
@@ -378,6 +438,9 @@ class _EmailForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지.
|
||||
/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다.
|
||||
const String keyPendingBadge = '키 대기';
|
||||
|
||||
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
|
||||
abstract final class EmailFormMetrics {
|
||||
|
||||
Reference in New Issue
Block a user