Backend (Go): - Project structure with chi router, pgxpool, goose migrations - JWT auth (access/refresh tokens) with Firebase token verification - NoopTokenVerifier for local dev without Firebase credentials - PostgreSQL user repository with atomic profile updates (transactions) - Mifflin-St Jeor calorie calculation based on profile data - REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health - Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id - Unit tests (51 passing) and integration tests (testcontainers) - Docker Compose setup with postgres healthcheck and graceful shutdown Flutter client: - Riverpod state management with GoRouter navigation - Firebase Auth (email/password + Google sign-in with web popup support) - Platform-aware API URLs (web/Android/iOS) - Dio HTTP client with JWT auth interceptor and concurrent refresh handling - Secure token storage - Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile) - Unit tests (17 passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
6.8 KiB
Dart
191 lines
6.8 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../core/auth/auth_provider.dart';
|
||
import '../../core/theme/app_colors.dart';
|
||
|
||
class LoginScreen extends ConsumerStatefulWidget {
|
||
const LoginScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||
}
|
||
|
||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
final _emailController = TextEditingController();
|
||
final _passwordController = TextEditingController();
|
||
|
||
@override
|
||
void dispose() {
|
||
_emailController.dispose();
|
||
_passwordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final authState = ref.watch(authProvider);
|
||
|
||
ref.listen<AuthState>(authProvider, (previous, next) {
|
||
if (next.status == AuthStatus.authenticated) {
|
||
context.go('/home');
|
||
}
|
||
if (next.error != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(next.error!),
|
||
backgroundColor: AppColors.error,
|
||
),
|
||
);
|
||
}
|
||
});
|
||
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Form(
|
||
key: _formKey,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const SizedBox(height: 60),
|
||
Icon(
|
||
Icons.restaurant_menu,
|
||
size: 80,
|
||
color: AppColors.primary,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'FoodAI',
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
color: AppColors.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Управляйте питанием\nс помощью AI',
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: 48),
|
||
TextFormField(
|
||
controller: _emailController,
|
||
keyboardType: TextInputType.emailAddress,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Email',
|
||
prefixIcon: Icon(Icons.email_outlined),
|
||
),
|
||
validator: (value) {
|
||
if (value == null || value.isEmpty) {
|
||
return 'Введите email';
|
||
}
|
||
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
|
||
return 'Некорректный email';
|
||
}
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextFormField(
|
||
controller: _passwordController,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Пароль',
|
||
prefixIcon: Icon(Icons.lock_outlined),
|
||
),
|
||
validator: (value) {
|
||
if (value == null || value.isEmpty) {
|
||
return 'Введите пароль';
|
||
}
|
||
if (value.length < 6) {
|
||
return 'Минимум 6 символов';
|
||
}
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 24),
|
||
ElevatedButton(
|
||
onPressed: authState.isLoading ? null : _signInWithEmail,
|
||
child: authState.isLoading
|
||
? const SizedBox(
|
||
height: 20,
|
||
width: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Войти'),
|
||
),
|
||
const SizedBox(height: 24),
|
||
Row(
|
||
children: [
|
||
const Expanded(child: Divider()),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Text(
|
||
'или',
|
||
style: TextStyle(color: AppColors.textSecondary),
|
||
),
|
||
),
|
||
const Expanded(child: Divider()),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
OutlinedButton.icon(
|
||
onPressed: authState.isLoading ? null : _signInWithGoogle,
|
||
icon: const Text('G',
|
||
style: TextStyle(
|
||
fontSize: 18, fontWeight: FontWeight.bold)),
|
||
label: const Text('Войти через Google'),
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(double.infinity, 48),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
if (defaultTargetPlatform == TargetPlatform.iOS) ...[
|
||
const SizedBox(height: 12),
|
||
OutlinedButton.icon(
|
||
onPressed: authState.isLoading ? null : () {},
|
||
icon: const Icon(Icons.apple, size: 24),
|
||
label: const Text('Войти через Apple'),
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size(double.infinity, 48),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 24),
|
||
TextButton(
|
||
onPressed: () => context.go('/auth/register'),
|
||
child: const Text('Нет аккаунта? Регистрация'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _signInWithEmail() {
|
||
if (_formKey.currentState!.validate()) {
|
||
ref.read(authProvider.notifier).signInWithEmail(
|
||
_emailController.text.trim(),
|
||
_passwordController.text,
|
||
);
|
||
}
|
||
}
|
||
|
||
void _signInWithGoogle() {
|
||
ref.read(authProvider.notifier).signInWithGoogle();
|
||
}
|
||
}
|