import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:shared_preferences/shared_preferences.dart'; enum AuthStatus { signedOut, signingIn, signedIn, error } /// Authentication through the **API Server** (login only, for now). /// /// The app talks only to the API Server; PocketBase is hidden behind it. The /// API Server address is configurable on the login screen ("Server settings"). /// A single global [auth] instance is shared across the app. class PbAuth { static const String defaultServer = 'http://10.2.1.101:8080'; SharedPreferences? _prefs; String? _token; String _email = ''; final StreamController _statusController = StreamController.broadcast(); Stream get status => _statusController.stream; AuthStatus _status = AuthStatus.signedOut; AuthStatus get currentStatus => _status; bool get isAuthed => _token != null && _token!.isNotEmpty; String get userEmail => _email; String get token => _token ?? ''; String get serverUrl => _prefs?.getString('api_url') ?? defaultServer; /// A previously successful password login can be replayed via biometrics. /// Credentials are stored on-device (see [signIn]); presence of a saved /// password means "remembered account" and unlocks the biometric buttons. bool get hasRememberedAccount => (_prefs?.getString('remember_password') ?? '').isNotEmpty; String get rememberedEmail => _prefs?.getString('remember_email') ?? ''; void _set(AuthStatus s) { _status = s; if (!_statusController.isClosed) _statusController.add(s); } /// Restores a persisted session (if any) on app start. Future init() async { _prefs = await SharedPreferences.getInstance(); _token = _prefs!.getString('api_token'); _email = _prefs!.getString('api_email') ?? ''; if (isAuthed) _set(AuthStatus.signedIn); } Future signIn({ required String serverUrl, required String email, required String password, }) async { final String base = _normalize(serverUrl); _set(AuthStatus.signingIn); try { final Map result = await _postLogin(base, email.trim(), password); final String? token = result['token'] as String?; if (token == null || token.isEmpty) { throw const _AuthException('Unexpected response from the API server.'); } _token = token; _email = ((result['record'] as Map?)?['email'] as String?) ?? email.trim(); await _prefs?.setString('api_url', base); await _prefs?.setString('api_token', _token!); await _prefs?.setString('api_email', _email); // Remember the credentials so a later biometric/face check can replay them. // NOTE: stored in plain SharedPreferences like the token above — move to // flutter_secure_storage (Keystore) when hardening. await _prefs?.setString('remember_email', email.trim()); await _prefs?.setString('remember_password', password); _set(AuthStatus.signedIn); } catch (e) { _set(AuthStatus.error); rethrow; } } /// Replays the remembered credentials — call this only after the caller has /// passed a device biometric / face check. Future signInWithRememberedCredentials() async { final String email = _prefs?.getString('remember_email') ?? ''; final String password = _prefs?.getString('remember_password') ?? ''; if (password.isEmpty) { throw const _AuthException('No remembered account. Sign in with your password once first.'); } await signIn(serverUrl: serverUrl, email: email, password: password); } /// Clears the remembered credentials (disables biometric quick-login) without /// necessarily ending the current session. Future forgetAccount() async { await _prefs?.remove('remember_email'); await _prefs?.remove('remember_password'); } Future> _postLogin(String base, String email, String password) async { final HttpClient http = HttpClient()..connectionTimeout = const Duration(seconds: 10); try { final HttpClientRequest req = await http.postUrl(Uri.parse('$base/api/auth/login')); req.headers.contentType = ContentType.json; req.add(utf8.encode(jsonEncode({'email': email, 'password': password}))); final HttpClientResponse resp = await req.close().timeout(const Duration(seconds: 12)); final String text = await resp.transform(utf8.decoder).join(); final Map body = text.isNotEmpty ? (jsonDecode(text) as Map).cast() : {}; if (resp.statusCode == 200) return body; if (resp.statusCode == 400) throw const _AuthException('Invalid email or password.'); if (resp.statusCode == 502) throw const _AuthException("API server can't reach PocketBase."); throw _AuthException( (body['message'] ?? body['error'] ?? 'Login failed (${resp.statusCode})').toString(), ); } finally { http.close(force: true); } } Future signOut() async { _token = null; _email = ''; await _prefs?.remove('api_token'); await _prefs?.remove('api_email'); _set(AuthStatus.signedOut); } String _normalize(String url) { String u = url.trim(); if (u.isEmpty) return defaultServer; if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u'; if (u.endsWith('/')) u = u.substring(0, u.length - 1); return u; } } class _AuthException implements Exception { const _AuthException(this.message); final String message; @override String toString() => message; } /// App-wide authentication instance. final PbAuth auth = PbAuth();