Files
minstrel/flutter_client/lib/auth/login_screen.dart
T
bvandeusen 9cbbfcff6c feat(flutter/auth): login screen + AuthApi.login posting to /api/auth/login
Login uses a non-authenticated dio (token resolver returns null) since
we don't have one yet. On success, setSession persists token + user
into secure storage and the AuthController state flips, which the
router watches to navigate.
2026-05-02 17:21:42 -04:00

95 lines
3.1 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/client.dart';
import '../api/endpoints/auth.dart';
import '../api/error_copy.dart';
import '../api/errors.dart';
import '../theme/theme_extension.dart';
import 'auth_provider.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _busy = false;
String? _error;
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
try {
final url = await ref.read(serverUrlProvider.future);
if (url == null) {
if (!mounted) return;
Navigator.of(context).pushReplacementNamed('/server-url');
return;
}
final dio = ApiClient.buildDio(
baseUrl: url,
tokenResolver: () async => null,
);
final res = await AuthApi(dio).login(
username: _user.text.trim(),
password: _pass.text,
);
await ref.read(authControllerProvider.notifier).setSession(
token: res.token,
userJson: res.rawUserJson,
);
if (!mounted) return;
Navigator.of(context).pushReplacementNamed('/home');
} on DioException catch (e) {
final code = ApiError.fromDio(e).code;
final copy = (await ErrorCopy.load()).forCode(code);
if (mounted) setState(() => _error = copy);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
const SizedBox(height: 48),
Text('Sign in', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)),
const SizedBox(height: 24),
TextField(controller: _user, decoration: const InputDecoration(labelText: 'Username')),
const SizedBox(height: 12),
TextField(controller: _pass, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
if (_error != null) Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: TextStyle(color: fs.error)),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _busy ? null : _submit,
child: _busy
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Sign in'),
),
TextButton(
onPressed: () => Navigator.of(context).pushReplacementNamed('/server-url'),
child: const Text('Change server URL'),
),
]),
),
),
);
}
}