9cbbfcff6c
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.
95 lines
3.1 KiB
Dart
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'),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|