Files
minstrel/flutter_client/lib/auth/login_screen.dart
T
bvandeusen d115c267f4 feat(flutter): GoRouter shell + version gate + auth-aware redirects
Cold launch flow: no server-url -> /server-url. URL set, no token ->
/login. Token present -> /home with shell. VersionGate runs once when
the URL changes, hits /healthz, blocks if min_client_version > package
version. /home is a placeholder until Task 14.

Replaces Navigator.pushReplacementNamed in server_url_screen +
login_screen with context.go now that go_router owns the routes.
2026-05-02 17:24:08 -04:00

96 lines
3.1 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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;
context.go('/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;
context.go('/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: () => context.go('/server-url'),
child: const Text('Change server URL'),
),
]),
),
),
);
}
}