Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
97 lines
3.4 KiB
Dart
97 lines
3.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/auth/auth_controller.dart';
|
|
import '../../core/config.dart';
|
|
|
|
class LoginPage extends ConsumerStatefulWidget {
|
|
const LoginPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<LoginPage> createState() => _LoginPageState();
|
|
}
|
|
|
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
String? _error;
|
|
bool _busy = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_email.dispose();
|
|
_password.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
final error = await ref
|
|
.read(authControllerProvider.notifier)
|
|
.login(_email.text.trim(), _password.text);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_busy = false;
|
|
_error = error;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final status = ref.watch(authControllerProvider).status;
|
|
return Scaffold(
|
|
body: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 360),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: status == AuthStatus.unknown
|
|
? const CircularProgressIndicator()
|
|
: AutofillGroup(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text('fin-tracker', style: Theme.of(context).textTheme.headlineMedium),
|
|
const SizedBox(height: 4),
|
|
Text(apiBaseUrl(), style: Theme.of(context).textTheme.bodySmall),
|
|
const SizedBox(height: 24),
|
|
TextField(
|
|
controller: _email,
|
|
autofillHints: const [AutofillHints.username],
|
|
keyboardType: TextInputType.emailAddress,
|
|
decoration: const InputDecoration(labelText: 'Email'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _password,
|
|
autofillHints: const [AutofillHints.password],
|
|
obscureText: true,
|
|
onSubmitted: (_) => _busy ? null : _submit(),
|
|
decoration: const InputDecoration(labelText: 'Пароль'),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
],
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _busy ? null : _submit,
|
|
child: _busy
|
|
? const SizedBox.square(
|
|
dimension: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Text('Войти'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|