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.
This commit is contained in:
2026-05-02 17:21:42 -04:00
parent 3cf874ab69
commit 9cbbfcff6c
3 changed files with 146 additions and 0 deletions
@@ -0,0 +1,33 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../models/user.dart';
class AuthApi {
AuthApi(this._dio);
final Dio _dio;
/// Returns (token, user, rawUserJson) on success. Server emits
/// LoginResponse{token, user{id, username, is_admin}}.
Future<({String token, User user, String rawUserJson})> login({
required String username,
required String password,
}) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/auth/login',
data: {'username': username, 'password': password},
);
final body = r.data!;
final userMap = (body['user'] as Map).cast<String, dynamic>();
return (
token: body['token'] as String,
user: User.fromJson(userMap),
rawUserJson: jsonEncode(userMap),
);
}
Future<void> logout() async {
await _dio.post<void>('/api/auth/logout');
}
}
+94
View File
@@ -0,0 +1,94 @@
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'),
),
]),
),
),
);
}
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/auth/login_screen.dart';
import 'package:minstrel/theme/theme_data.dart';
void main() {
testWidgets('login screen renders both fields and the submit button', (tester) async {
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const LoginScreen(),
),
));
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('Sign in'), findsNWidgets(2)); // header + button
});
}