diff --git a/flutter_client/lib/api/endpoints/auth.dart b/flutter_client/lib/api/endpoints/auth.dart new file mode 100644 index 00000000..0f5b2983 --- /dev/null +++ b/flutter_client/lib/api/endpoints/auth.dart @@ -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>( + '/api/auth/login', + data: {'username': username, 'password': password}, + ); + final body = r.data!; + final userMap = (body['user'] as Map).cast(); + return ( + token: body['token'] as String, + user: User.fromJson(userMap), + rawUserJson: jsonEncode(userMap), + ); + } + + Future logout() async { + await _dio.post('/api/auth/logout'); + } +} diff --git a/flutter_client/lib/auth/login_screen.dart b/flutter_client/lib/auth/login_screen.dart new file mode 100644 index 00000000..3847248c --- /dev/null +++ b/flutter_client/lib/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _user = TextEditingController(); + final _pass = TextEditingController(); + bool _busy = false; + String? _error; + + Future _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()!; + 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'), + ), + ]), + ), + ), + ); + } +} diff --git a/flutter_client/test/auth/login_screen_test.dart b/flutter_client/test/auth/login_screen_test.dart new file mode 100644 index 00000000..e3d8c7f3 --- /dev/null +++ b/flutter_client/test/auth/login_screen_test.dart @@ -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 + }); +}