Flutter 앱 골격 · 5탭 네비게이션 · 로그인 화면 · Firestore 보안 규칙·인덱스
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'auth.dart';
|
||||
import 'auth_repository.dart';
|
||||
|
||||
/// S-A-LOGIN 로그인.
|
||||
///
|
||||
/// 시안: `시안/screens/S-A-LOGIN.html` · 명세: `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md`
|
||||
/// 골격 범위라 4개 버튼과 결과 안내까지만 구현했다. 약관 동의(S-A-TERMS) 이후 동선은 3주차다.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key, required this.auth});
|
||||
|
||||
final AuthRepository auth;
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _notice;
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _signIn(SocialProvider provider) async {
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_notice = switch (outcome) {
|
||||
AuthSucceeded() => null,
|
||||
AuthCancelled() => null,
|
||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||
AuthNotConfigured(:final provider, :final missing) =>
|
||||
'${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n'
|
||||
'필요한 항목: ${missing.join(' · ')}',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@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.s6),
|
||||
// 로고 원본은 미확정(Q6)이라 워드마크를 텍스트로 둔다. 확정되면 이미지로 교체.
|
||||
Text('Fancake', style: text.displayMedium),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text('당신의 팬 활동을\n하나의 행성으로', style: text.displayLarge),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Text(
|
||||
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
|
||||
style: text.bodyMedium?.copyWith(color: AppColors.onCosmicVariant),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s7),
|
||||
Text('계정으로 시작하기', style: text.titleLarge),
|
||||
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,
|
||||
enabled: !_busy,
|
||||
onPressed: () => _signIn(p.provider),
|
||||
),
|
||||
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.enabled,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final SocialProvider provider;
|
||||
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.naver => (BrandColors.naver, BrandColors.naverLabel, 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: Text(provider.label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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('목 세션으로 탭 둘러보기'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user