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:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user