Files
food-ai/client/lib/features/auth/login_screen.dart
dbastrikin 24219b611e feat: implement Iteration 0 foundation (backend + Flutter client)
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>
2026-02-20 13:14:58 +02:00

191 lines
6.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}