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>
159 lines
5.5 KiB
Dart
159 lines
5.5 KiB
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 RegisterScreen extends ConsumerStatefulWidget {
|
|
const RegisterScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
|
}
|
|
|
|
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
final _confirmPasswordController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_confirmPasswordController.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(
|
|
appBar: AppBar(title: const Text('Регистрация')),
|
|
body: SafeArea(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const SizedBox(height: 24),
|
|
TextFormField(
|
|
controller: _nameController,
|
|
textCapitalization: TextCapitalization.words,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Имя',
|
|
prefixIcon: Icon(Icons.person_outlined),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Введите имя';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
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: 16),
|
|
TextFormField(
|
|
controller: _confirmPasswordController,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Подтверждение пароля',
|
|
prefixIcon: Icon(Icons.lock_outlined),
|
|
),
|
|
validator: (value) {
|
|
if (value != _passwordController.text) {
|
|
return 'Пароли не совпадают';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 32),
|
|
ElevatedButton(
|
|
onPressed: authState.isLoading ? null : _register,
|
|
child: authState.isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Зарегистрироваться'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextButton(
|
|
onPressed: () => context.go('/auth/login'),
|
|
child: const Text('Уже есть аккаунт? Войти'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _register() {
|
|
if (_formKey.currentState!.validate()) {
|
|
ref.read(authProvider.notifier).register(
|
|
_emailController.text.trim(),
|
|
_passwordController.text,
|
|
_nameController.text.trim(),
|
|
);
|
|
}
|
|
}
|
|
}
|