Files
fanuzu/app/lib/features/auth/login_screen.dart
T

451 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/firebase/firebase_boot.dart';
import '../../core/theme/tokens.dart';
import 'auth.dart';
import 'auth_repository.dart';
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:818` · `510:86330`).
///
/// **로그인 수단은 별첨 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 {
const LoginScreen({super.key, required this.auth});
final AuthRepository auth;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _form = GlobalKey<FormState>();
String? _notice;
bool _busy = false;
/// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외).
bool _qaFormOpen = false;
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
bool _signUp = false;
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submitEmail() async {
if (!(_form.currentState?.validate() ?? false)) return;
setState(() => _busy = true);
final outcome = await widget.auth.signInWithEmail(
email: _email.text,
password: _password.text,
signUp: _signUp,
);
if (!mounted) return;
setState(() {
_busy = false;
_notice = _noticeOf(outcome);
});
}
Future<void> _resetPassword() async {
final email = _email.text.trim();
if (email.isEmpty) {
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.');
return;
}
final impl = widget.auth.qaEmailProvider;
if (impl == null) return;
setState(() => _busy = true);
final outcome = await impl.sendPasswordReset(email);
if (!mounted) return;
setState(() {
_busy = false;
// 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다.
_notice = outcome is AuthFailed
? _noticeOf(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(' · ')}',
};
Future<void> _signIn(SocialProvider provider) async {
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider);
if (!mounted) return;
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;
return Scaffold(
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,
),
alignment: Alignment.center,
child: Text(
'FANUZU',
style: text.titleLarge?.copyWith(
color: AppColors.onCosmicAccent,
fontFamily: AppFonts.display,
fontWeight: FontWeight.w700,
),
),
),
),
),
const SizedBox(height: AppSpace.s6),
Text(
'당신의 팬 활동을\n하나의 행성으로',
style: text.headlineSmall?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
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),
],
),
),
),
);
}
}
/// 소셜 버튼. 색과 라벨은 각 사 브랜드 가이드 고정값이라 테마를 타지 않는다.
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;
@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,
),
};
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),
),
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),
),
),
],
],
),
);
}
}
class _Notice extends StatelessWidget {
const _Notice(this.message);
final String message;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
border: Border.all(color: AppColors.cosmicOutline),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(message, style: Theme.of(context).textTheme.bodySmall),
);
}
/// Firebase 미설정 안내 + 탭 이동 시연 입구.
class _MockBanner extends StatelessWidget {
const _MockBanner({required this.onEnter});
final VoidCallback onEnter;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('목(mock) 모드', style: text.labelMedium),
const SizedBox(height: AppSpace.s2),
Text(FirebaseBoot.reason, style: text.bodySmall),
const SizedBox(height: AppSpace.s3),
OutlinedButton(onPressed: onEnter, child: const Text('목 세션으로 탭 둘러보기')),
],
);
}
}
/// QA 계정 전용 이메일 입력 폼 — 숨은 입구로만 열린다.
///
/// Figma 「로그인 입력 영역」 명세 —
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
/// *로그인 버튼 - 필수값 입력 시 활성화*
class _EmailForm extends StatelessWidget {
const _EmailForm({
required this.formKey,
required this.email,
required this.password,
required this.signUp,
required this.busy,
required this.onSubmit,
required this.onToggleMode,
required this.onReset,
});
final GlobalKey<FormState> formKey;
final TextEditingController email;
final TextEditingController password;
final bool signUp;
final bool busy;
final VoidCallback onSubmit;
final VoidCallback onToggleMode;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Form(
key: formKey,
child: Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(signUp ? '이메일로 가입' : '이메일로 로그인', style: t.titleMedium),
const SizedBox(height: AppSpace.s3),
TextFormField(
controller: email,
enabled: !busy,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(labelText: '이메일'),
validator: (value) {
final v = (value ?? '').trim();
if (v.isEmpty) return '이메일을 입력해 주세요.';
// 형식 검사는 최소한만 한다. 지나치게 엄격한 정규식은 유효한
// 주소를 막는다 — 실제 검증은 가입 확인 메일이 한다.
if (!v.contains('@') || v.startsWith('@') || v.endsWith('@')) {
return '이메일 형식이 올바르지 않습니다.';
}
return null;
},
),
const SizedBox(height: AppSpace.s3),
TextFormField(
controller: password,
enabled: !busy,
obscureText: true,
autofillHints: const [AutofillHints.password],
decoration: const InputDecoration(labelText: '비밀번호'),
validator: (value) {
final v = value ?? '';
if (v.isEmpty) return '비밀번호를 입력해 주세요.';
if (signUp && v.length < EmailFormMetrics.minPasswordLength) {
return '${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.';
}
return null;
},
),
const SizedBox(height: AppSpace.s4),
FilledButton(
onPressed: busy ? null : onSubmit,
child: Text(signUp ? '가입하기' : '로그인'),
),
const SizedBox(height: AppSpace.s2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: busy ? null : onToggleMode,
child: Text(signUp ? '이미 계정이 있어요' : '계정 만들기'),
),
if (!signUp)
TextButton(
onPressed: busy ? null : onReset,
child: const Text('비밀번호 재설정'),
),
],
),
],
),
),
);
}
}
/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지.
/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다.
const String keyPendingBadge = '키 대기';
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
abstract final class EmailFormMetrics {
/// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 —
/// 6자는 사전 공격에 사실상 무방비다.
static const minPasswordLength = 8;
}