diff --git a/arbeitskacheln/23_flutter_begleitapp.md b/arbeitskacheln/23_flutter_begleitapp.md index 30845d4..78dc5d7 100644 --- a/arbeitskacheln/23_flutter_begleitapp.md +++ b/arbeitskacheln/23_flutter_begleitapp.md @@ -77,9 +77,27 @@ Build-Host, kein Dauerbetrieb der App dort (App läuft auf Endgeräten). nicht ausgewertet** — dafür bräuchte es einen bewussten Vergleichstest unter Feldbedingungen, nicht nur "die App existiert und funktioniert". Diese Bewertung steht weiterhin aus, unabhängig vom App-Reifegrad. - - Code lebt vollständig auf dem separaten Build-Server - (`/opt/flutter-projects/mabea_scan_prototyp/`), **nicht** im MABEA-Git-Repo - — dieser Kachel-Eintrag ist die einzige Dokumentation davon. + - **Korrektur 2026-09-10:** entgegen der ursprünglichen Notiz hier liegt der + Code inzwischen DOCH im MABEA-Git-Repo (`flutter_app/`, getrackt) — der + separate Build-Server (`/opt/flutter-projects/mabea_scan_prototyp/`) wird + weiterhin fürs eigentliche APK-Bauen genutzt, ist aber nicht mehr die + einzige Quelle. Kein lokales Flutter/Dart-SDK in dieser Entwicklungs- + umgebung verfügbar — Änderungen hier werden per `ast`-artiger manueller + Code-Review statt automatisiertem `flutter analyze`/`flutter test` + verifiziert, echte CI-Prüfung fehlt für dieses Verzeichnis noch. + - **Zugangsdaten-Persistenz (Nutzer-Vorgabe 2026-09-10):** Login-Token + + optional Benutzername/Passwort jetzt über `flutter_secure_storage` + (verschlüsselter Android-Keystore/iOS-Keychain) statt der bisherigen + `shared_preferences` (Klartext) — löst zwei Dinge: (1) sicherer Speicher + für das ohnehin schon persistierte Token, (2) NEU: automatischer + Re-Login mit gespeicherten Zugangsdaten bei abgelaufenem Token (`ApiClient + .versucheAutoRelogin()`, greift transparent bei jeder 401-Antwort in + `request()`), damit die App auch nach Ablauf der 8h-Token-Gültigkeit ohne + erneute manuelle Eingabe weiterläuft, sobald wieder eine Verbindung + besteht. Speichern der Zugangsdaten ist eine bewusste Checkbox auf dem + Login-Screen (Default an, abwählbar) — kein stiller Zwang, Passwort auf + dem Gerät abzulegen. Ein Abwählen löscht auch bereits gespeicherte + Zugangsdaten (kein wirkungsloses nachträgliches Opt-out). ## FLUT-002 — voller Kontroll-Workflow in der App (nicht beauftragt, nur Frage beantwortet) diff --git a/flutter_app/lib/api/api_client.dart b/flutter_app/lib/api/api_client.dart index d8f308f..a25c388 100644 --- a/flutter_app/lib/api/api_client.dart +++ b/flutter_app/lib/api/api_client.dart @@ -1,12 +1,21 @@ import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; /// Gleiche Basis-Idee wie im React-Client (frontend/src/api/client.ts): /// gemeinsamer JSON-Wrapper, Token aus persistentem Speicher, ApiException /// mit HTTP-Status, damit aufrufender Code gezielt reagieren kann (z.B. 409 /// bei Objekt-Sperre). +/// +/// Nutzer-Vorgabe 2026-09-10: Zugangsdaten werden zusätzlich zum Token +/// gespeichert, damit die App nach Token-Ablauf (Backend: 8h) automatisch +/// neu einloggen kann, sobald wieder eine Verbindung besteht - ohne dass der +/// Helfer im Feld erneut Login/Passwort eintippen muss. Speicherung über +/// flutter_secure_storage (Android Keystore/iOS Keychain, verschlüsselt), +/// NICHT SharedPreferences (Klartext) - Passwort-Speicherung auf dem Gerät +/// erhöht das Risiko bei Geräteverlust, daher zwingend verschlüsselter +/// Speicher statt der bisherigen Token-only-Lösung. class ApiException implements Exception { final int status; final dynamic detail; @@ -17,30 +26,53 @@ class ApiException implements Exception { } class ApiClient { - ApiClient({required this.baseUrl, http.Client? httpClient}) : _http = httpClient ?? http.Client(); + ApiClient({required this.baseUrl, http.Client? httpClient, FlutterSecureStorage? storage}) + : _http = httpClient ?? http.Client(), + _storage = storage ?? const FlutterSecureStorage(); + + static const _tokenKey = 'mabea_token'; + static const _loginKey = 'mabea_login'; + static const _passwortKey = 'mabea_passwort'; final String baseUrl; final http.Client _http; + final FlutterSecureStorage _storage; String? _token; Future ladeToken() async { - final prefs = await SharedPreferences.getInstance(); - _token = prefs.getString('mabea_token'); + _token = await _storage.read(key: _tokenKey); } Future setzeToken(String? token) async { _token = token; - final prefs = await SharedPreferences.getInstance(); if (token == null) { - await prefs.remove('mabea_token'); + await _storage.delete(key: _tokenKey); } else { - await prefs.setString('mabea_token', token); + await _storage.write(key: _tokenKey, value: token); } } bool get istEingeloggt => _token != null; - Future login(String username, String password) async { + /// [zugangsdatenSpeichern] ist die Nutzer-Entscheidung auf dem Login-Screen + /// (Checkbox) - bei false werden auch eventuell noch vorhandene, früher + /// gespeicherte Zugangsdaten gelöscht, nicht nur keine neuen angelegt + /// (sonst würde ein nachträgliches Abwählen wirkungslos bleiben). + Future login(String username, String password, {bool zugangsdatenSpeichern = true}) async { + final token = await _fuehreLoginAus(username, password); + if (zugangsdatenSpeichern) { + // Erst NACH erfolgreichem Login speichern - kein Ablegen falscher + // Zugangsdaten bei einem Tippfehler. + await _storage.write(key: _loginKey, value: username); + await _storage.write(key: _passwortKey, value: password); + } else { + await _storage.delete(key: _loginKey); + await _storage.delete(key: _passwortKey); + } + return token; + } + + Future _fuehreLoginAus(String username, String password) async { final response = await _http.post( Uri.parse('$baseUrl/auth/login'), headers: {'Content-Type': 'application/x-www-form-urlencoded'}, @@ -55,10 +87,55 @@ class ApiClient { return token; } + /// Versucht, mit den zuletzt gespeicherten Zugangsdaten neu einzuloggen - + /// z.B. wenn der Token beim App-Start bereits abgelaufen ist oder eine + /// Anfrage mit 401 fehlschlägt, während wieder eine Verbindung besteht. + /// Gibt false zurück statt zu werfen, wenn keine Zugangsdaten gespeichert + /// sind oder der erneute Login selbst fehlschlägt (z.B. Passwort inzwischen + /// geändert) - Aufrufer fällt dann auf den normalen Login-Screen zurück. + Future versucheAutoRelogin() async { + final login = await _storage.read(key: _loginKey); + final passwort = await _storage.read(key: _passwortKey); + if (login == null || passwort == null) return false; + try { + await _fuehreLoginAus(login, passwort); + return true; + } catch (_) { + return false; + } + } + + /// Löscht Token UND gespeicherte Zugangsdaten - echter Logout, nicht nur + /// Token-Verwerfen (sonst würde die App sich beim nächsten Start/401 sofort + /// wieder automatisch einloggen). + Future logout() async { + _token = null; + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _loginKey); + await _storage.delete(key: _passwortKey); + } + Future request( String path, { String method = 'GET', Object? body, + }) async { + try { + return await _sendeAnfrage(path, method: method, body: body); + } on ApiException catch (e) { + if (e.status != 401) rethrow; + // Token vermutlich abgelaufen - einmaliger automatischer Neuversuch mit + // den gespeicherten Zugangsdaten, bevor der Nutzer zum Login-Screen muss. + final erfolgreich = await versucheAutoRelogin(); + if (!erfolgreich) rethrow; + return await _sendeAnfrage(path, method: method, body: body); + } + } + + Future _sendeAnfrage( + String path, { + required String method, + Object? body, }) async { final uri = Uri.parse('$baseUrl$path'); final headers = { diff --git a/flutter_app/lib/screens/login_screen.dart b/flutter_app/lib/screens/login_screen.dart index d81c8e0..e8beca5 100644 --- a/flutter_app/lib/screens/login_screen.dart +++ b/flutter_app/lib/screens/login_screen.dart @@ -16,6 +16,11 @@ class _LoginScreenState extends State { final _passwortController = TextEditingController(); String? _fehler; bool _ladeVorgang = false; + // Nutzer-Vorgabe 2026-09-10: Speichern der Zugangsdaten (für automatischen + // Neu-Login nach Token-Ablauf, auch offline nutzbar) ist bewusst eine + // aktive Wahl auf dem Login-Screen, kein stiller Standard - Default an, + // damit der Offline-Nutzen ohne Zutun greift, aber jederzeit abwählbar. + bool _zugangsdatenSpeichern = true; Future _anmelden() async { setState(() { @@ -23,7 +28,11 @@ class _LoginScreenState extends State { _ladeVorgang = true; }); try { - await widget.apiClient.login(_loginController.text, _passwortController.text); + await widget.apiClient.login( + _loginController.text, + _passwortController.text, + zugangsdatenSpeichern: _zugangsdatenSpeichern, + ); if (!mounted) return; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => ObjektListScreen(apiClient: widget.apiClient)), @@ -57,6 +66,13 @@ class _LoginScreenState extends State { decoration: const InputDecoration(labelText: 'Passwort'), obscureText: true, ), + CheckboxListTile( + value: _zugangsdatenSpeichern, + onChanged: (wert) => setState(() => _zugangsdatenSpeichern = wert ?? true), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + title: const Text('Zugangsdaten speichern (auch offline nutzbar)'), + ), if (_fehler != null) ...[ const SizedBox(height: 12), Text(_fehler!, style: const TextStyle(color: Colors.red)), diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 4a072c1..76efac7 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: flutter: sdk: flutter http: ^1.2.2 - shared_preferences: ^2.3.2 + flutter_secure_storage: ^9.2.2 mobile_scanner: ^5.2.3 sqflite: ^2.3.3 path: ^1.9.0 diff --git a/flutter_app/test/api_client_test.dart b/flutter_app/test/api_client_test.dart index f7a963d..e356147 100644 --- a/flutter_app/test/api_client_test.dart +++ b/flutter_app/test/api_client_test.dart @@ -3,10 +3,11 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:mabea_app/api/api_client.dart'; +import 'fakes/fake_secure_storage.dart'; + class _MockClient extends Mock implements http.Client {} void main() { @@ -14,9 +15,13 @@ void main() { late ApiClient apiClient; setUp(() { - SharedPreferences.setMockInitialValues({}); + FakeSecureStorage.zuruecksetzen(); httpClient = _MockClient(); - apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + apiClient = ApiClient( + baseUrl: 'http://test.local/api/v1', + httpClient: httpClient, + storage: const FakeSecureStorage(), + ); }); group('login', () { diff --git a/flutter_app/test/fakes/fake_secure_storage.dart b/flutter_app/test/fakes/fake_secure_storage.dart new file mode 100644 index 0000000..9776a3b --- /dev/null +++ b/flutter_app/test/fakes/fake_secure_storage.dart @@ -0,0 +1,54 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// In-Memory-Ersatz für FlutterSecureStorage in Tests - kein Plattform-Kanal +/// nötig (Android Keystore/iOS Keychain existieren im Test-Runner nicht). +class FakeSecureStorage extends FlutterSecureStorage { + const FakeSecureStorage(); + + static final Map _werte = {}; + + static void zuruecksetzen() => _werte.clear(); + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + _werte[key]; + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (value == null) { + _werte.remove(key); + } else { + _werte[key] = value; + } + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + _werte.remove(key); + } +} diff --git a/flutter_app/test/offline_queue_test.dart b/flutter_app/test/offline_queue_test.dart index 5a6bd2c..9574484 100644 --- a/flutter_app/test/offline_queue_test.dart +++ b/flutter_app/test/offline_queue_test.dart @@ -4,12 +4,13 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:mabea_app/api/api_client.dart'; import 'package:mabea_app/offline/queue.dart'; +import 'fakes/fake_secure_storage.dart'; + class _MockClient extends Mock implements http.Client {} /// OfflineQueue ist ein Singleton mit eigenem Datenbank-Handle (analog zur @@ -27,9 +28,13 @@ void main() { late ApiClient apiClient; setUp(() { - SharedPreferences.setMockInitialValues({}); + FakeSecureStorage.zuruecksetzen(); httpClient = _MockClient(); - apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + apiClient = ApiClient( + baseUrl: 'http://test.local/api/v1', + httpClient: httpClient, + storage: const FakeSecureStorage(), + ); }); test('enqueue speichert Eintrag, verschwindet nach erfolgreicher Übertragung', () async { diff --git a/flutter_app/test/widget/login_screen_test.dart b/flutter_app/test/widget/login_screen_test.dart index 3ed4976..ca9cdab 100644 --- a/flutter_app/test/widget/login_screen_test.dart +++ b/flutter_app/test/widget/login_screen_test.dart @@ -4,11 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:mabea_app/api/api_client.dart'; import 'package:mabea_app/screens/login_screen.dart'; +import '../fakes/fake_secure_storage.dart'; + class _MockClient extends Mock implements http.Client {} void main() { @@ -20,9 +21,13 @@ void main() { late ApiClient apiClient; setUp(() { - SharedPreferences.setMockInitialValues({}); + FakeSecureStorage.zuruecksetzen(); httpClient = _MockClient(); - apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + apiClient = ApiClient( + baseUrl: 'http://test.local/api/v1', + httpClient: httpClient, + storage: const FakeSecureStorage(), + ); }); testWidgets('erfolgreicher Login navigiert zur Objektliste', (tester) async { diff --git a/flutter_app/test/widget/objekt_list_screen_test.dart b/flutter_app/test/widget/objekt_list_screen_test.dart index c80c5bf..b112eea 100644 --- a/flutter_app/test/widget/objekt_list_screen_test.dart +++ b/flutter_app/test/widget/objekt_list_screen_test.dart @@ -4,11 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:mabea_app/api/api_client.dart'; import 'package:mabea_app/screens/objekt_list_screen.dart'; +import '../fakes/fake_secure_storage.dart'; + class _MockClient extends Mock implements http.Client {} void main() { @@ -20,9 +21,13 @@ void main() { late ApiClient apiClient; setUp(() { - SharedPreferences.setMockInitialValues({}); + FakeSecureStorage.zuruecksetzen(); httpClient = _MockClient(); - apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + apiClient = ApiClient( + baseUrl: 'http://test.local/api/v1', + httpClient: httpClient, + storage: const FakeSecureStorage(), + ); }); testWidgets('Suche filtert nach Name und Code', (tester) async {