Files
fin-tracker/app/lib/features/login/login_page.dart
T
Dmitry ce0966fca2 feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
2026-09-18 13:44:09 +03:00

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('Войти'),
),
],
),
),
),
),
),
);
}
}