diff --git a/Phone App/README.md b/Phone App/README.md index 93cbc83..c8761ba 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -15,11 +15,14 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an `IndexedStack`, so each section keeps its state as you switch tabs. - **Login** — email/password against `/api/auth/login`, password show/hide, and a - collapsible **Server settings** section to override the API base URL on-device. + collapsible **Server settings** section holding the address of the server the + form signs into — plus the way back to the others once more than one has been + added (see *More than one server*). - **Biometric / face sign-in + app lock** — see the dedicated section below. - **Garage (dashboard)** — car list with next-due status badges (date + km, worst-of), a "shared" chip on cars owned by someone else, pull-to-refresh and - an **Add car** FAB. + an **Add car** FAB. The header carries the **server picker** (see below), the + theme toggle and log out. - **Car detail** — all spec fields (incl. VIN and transmission / differential / brake / coolant specs), a **share** sheet (owner only), quick odometer update, edit car, and delete car (type-to-confirm; cascades). Actions are gated by the @@ -164,11 +167,41 @@ Android host requirements (already configured, don't revert): `MainActivity` extends **`FlutterFragmentActivity`** (required by `local_auth`), and `AndroidManifest.xml` declares `android.permission.USE_BIOMETRIC`. +## More than one server + +Two sites, two full DriverVault stacks — and one app. The server picker is the +first button in the Garage header: it names the server you are reading right +now, and switches between them in a tap. With only one server known there is +nothing to pick between, so it goes straight to adding the second. + +- **The home server** is the address this build ships with (`kDefaultApiBase`, + overridable per-device from the login screen's **Server settings**). It is + always in the list and can't be removed. +- **Any other server** is added by address — `https://garage.example.com`; the + `/api` is appended for you if you leave the path off — and has to be reachable + from wherever the phone is. +- **A session per server.** Each server is its own PocketBase with its own users, + so a token can't be carried across: you sign into each one once, and after that + switching needs no password. Sessions live in `SharedPreferences` under + `cc_session_`, the list under `cc_servers`, the active one under + `cc_active_server`. +- **Switching rebuilds the shell**, because record ids belong to the server that + issued them — the garage, the charging page and the settings all re-read from + the one now active, and its owner's appearance prefs come with it. +- **An expiring remote session doesn't sign you out of the app**: that server's + token is dropped, the app falls back to the home server, and the entry stays in + the list to sign into again. Only *Log out* clears every server at once. + +Upgrading from a single-server build carries what was there onto the home entry +— the saved session (`cc_token` / `cc_user`) and the address it was pointed at +(`cc_server_url`) — so nobody is signed out by the update. + ## Configure the API endpoint The app talks to `kDefaultApiBase` (see `lib/config.dart`), default `http://localhost:8080/api`. Override at build time with `--dart-define`, or at -runtime from the login screen's **Server settings** (persisted as `cc_server_url`). +runtime from the login screen's **Server settings**, which edits the address of +the active server — on a fresh install, the home one (persisted in `cc_servers`). ## Run & build @@ -199,7 +232,8 @@ lib/ ├── config.dart # default API base URL (kDefaultApiBase) ├── models.dart # Car (+ access getters), the record types, integrations, profile ├── api.dart # ApiClient — the only thing that calls the API Server -├── auth.dart # AuthService (token persistence, app-lock flag, ChangeNotifier) +├── auth.dart # AuthService — the active server's session, app-lock flag +├── servers.dart # ServerRegistry — the servers, their sessions, the active one ├── biometric.dart # BiometricAuth — local_auth + secure storage; biometricAuth singleton ├── app_settings.dart # AppSettings (theme/locale/date/font), persisted; drives MaterialApp ├── i18n.dart # translation lookup — t("key"); en/pl/da with en fallback @@ -215,5 +249,6 @@ lib/ ├── car_detail_screen.dart car_form_sheet.dart record_form_sheets.dart ├── car_view_sheet.dart # which tabs/rows/columns a car shows + the catalogues ├── provider_tab.dart # the connected-service tab (MyToyota) + ├── servers_sheet.dart # the server picker + the add / edit / sign-in sheet └── charging_screen.dart settings_screen.dart admin_users_screen.dart ``` diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 43b8278..1e7d182 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -55,6 +55,25 @@ "savedNote": "Gemt ✓", "resetNote": "Nulstillet ✓" }, + "servers": { + "title": "Servere", + "home": "Standardserver", + "switchHint": "Skift hvilken server appen læser fra", + "add": "Tilføj server", + "addTitle": "Tilføj en server", + "name": "Navn", + "namePlaceholder": "Hjemmegarage", + "url": "Adresse", + "urlHint": "API-serverens offentlige adresse, f.eks. https://garage.example.com — /api tilføjes automatisk, hvis du udelader stien.", + "urlHomeHint": "Lad feltet stå tomt for at bruge den adresse, appen leveres med.", + "connect": "Forbind", + "connecting": "Forbinder…", + "connected": "Forbundet", + "notConnected": "Ikke forbundet — log ind for at skifte", + "signOut": "Log ud af denne server", + "removeConfirm": "Fjern {name}? Den gemte session glemmes også.", + "unknown": "Ukendt server" + }, "lock": { "title": "DriverVault er låst", "reason": "Lås DriverVault op", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 24d28b3..3e2e9b2 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -55,6 +55,25 @@ "savedNote": "Saved ✓", "resetNote": "Reset ✓" }, + "servers": { + "title": "Servers", + "home": "Default server", + "switchHint": "Switch which server the app reads from", + "add": "Add server", + "addTitle": "Add a server", + "name": "Name", + "namePlaceholder": "Home garage", + "url": "Address", + "urlHint": "The API Server's public address, e.g. https://garage.example.com — /api is added for you if you leave the path off.", + "urlHomeHint": "Leave blank to use the address this app ships with.", + "connect": "Connect", + "connecting": "Connecting…", + "connected": "Connected", + "notConnected": "Not connected — sign in to switch", + "signOut": "Sign out of this server", + "removeConfirm": "Remove {name}? The session held for it is forgotten too.", + "unknown": "Unknown server" + }, "lock": { "title": "DriverVault is locked", "reason": "Unlock DriverVault", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 002b02c..ab30707 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -55,6 +55,25 @@ "savedNote": "Zapisano ✓", "resetNote": "Zresetowano ✓" }, + "servers": { + "title": "Serwery", + "home": "Serwer domyślny", + "switchHint": "Przełącz serwer, z którego korzysta aplikacja", + "add": "Dodaj serwer", + "addTitle": "Dodaj serwer", + "name": "Nazwa", + "namePlaceholder": "Garaż domowy", + "url": "Adres", + "urlHint": "Publiczny adres API Servera, np. https://garage.example.com — /api zostanie dodane, jeśli pominiesz ścieżkę.", + "urlHomeHint": "Zostaw puste, aby użyć adresu wbudowanego w aplikację.", + "connect": "Połącz", + "connecting": "Łączenie…", + "connected": "Połączono", + "notConnected": "Brak połączenia — zaloguj się, aby przełączyć", + "signOut": "Wyloguj z tego serwera", + "removeConfirm": "Usunąć {name}? Zapisana sesja również zostanie zapomniana.", + "unknown": "Nieznany serwer" + }, "lock": { "title": "DriverVault jest zablokowany", "reason": "Odblokuj DriverVault", diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index 95565e0..a71da62 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -1,9 +1,8 @@ import "dart:convert"; import "package:http/http.dart" as http; -import "package:shared_preferences/shared_preferences.dart"; -import "config.dart"; import "models.dart"; +import "servers.dart"; /// Thrown when the API Server returns a non-2xx response. class ApiException implements Exception { @@ -14,61 +13,56 @@ class ApiException implements Exception { String toString() => message; } -/// The single client for the Car Control API Server. Holds the bearer token and -/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can -/// route back to login. +/// One request's destination: the server that was active when it went out, its +/// base URL and its token. Pinning all three up front is what keeps a rejection +/// attributable — re-reading the active server on the way back would let one +/// server's 401 clear a different server's session when a switch lands between +/// a call going out and its answer arriving. +class _Target { + final String id; + final String base; + final String? token; + const _Target(this.id, this.base, this.token); + + Uri uri(String path) => Uri.parse("$base$path"); + + Map get authHeaders => + {if (token != null) "Authorization": "Bearer $token"}; + + Map get jsonHeaders => + {"Content-Type": "application/json", ...authHeaders}; +} + +/// The single client for the Car Control API Server. Which server the calls go +/// to is [serverRegistry]'s business: the base URL and the bearer token are both +/// read from whichever server is active, resolved fresh on every request so +/// switching takes effect without rebuilding the client. On 401 it calls +/// [onUnauthorized] with the server that rejected the session. class ApiClient { - String? token; - void Function()? onUnauthorized; + void Function(String serverId)? onUnauthorized; - static const _serverKey = "cc_server_url"; + /// The active server's token — read by the multipart and download helpers, + /// which build their own requests. + String? get token => serverRegistry.activeToken; - /// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override - /// (login screen "Server settings") replaces it via [loadServerUrl]. - String baseUrl = kDefaultApiBase; + /// The base URL the next request will go to. + String get baseUrl => serverRegistry.activeBase; - /// Loads a saved server-URL override, if any. Call before the first request. - Future loadServerUrl() async { - final prefs = await SharedPreferences.getInstance(); - final saved = prefs.getString(_serverKey); - if (saved != null && saved.isNotEmpty) baseUrl = saved; - } - - /// The current override URL, or "" when using the default. - Future serverOverride() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_serverKey) ?? ""; - } - - /// Persists a server-URL override. Blank clears it (reverts to the default). - /// Trailing slashes are trimmed. - Future setServerUrl(String url) async { - final prefs = await SharedPreferences.getInstance(); - final trimmed = url.trim().replaceAll(RegExp(r"/+$"), ""); - if (trimmed.isEmpty) { - await prefs.remove(_serverKey); - baseUrl = kDefaultApiBase; - } else { - await prefs.setString(_serverKey, trimmed); - baseUrl = trimmed; - } - } - - Map get _headers => { - "Content-Type": "application/json", - if (token != null) "Authorization": "Bearer $token", - }; - - Uri _uri(String path) => Uri.parse("$baseUrl$path"); + _Target _target() => _Target( + serverRegistry.activeId, + serverRegistry.activeBase, + serverRegistry.activeToken, + ); Future _send(String method, String path, {Object? body}) async { - final req = http.Request(method, _uri(path))..headers.addAll(_headers); + final target = _target(); + final req = http.Request(method, target.uri(path))..headers.addAll(target.jsonHeaders); if (body != null) req.body = jsonEncode(body); final streamed = await http.Client().send(req); final res = await http.Response.fromStream(streamed); if (res.statusCode == 401 && path != "/auth/login") { - onUnauthorized?.call(); + onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } if (res.statusCode == 204 || res.body.isEmpty) return null; @@ -102,14 +96,29 @@ class ApiClient { } // --- auth --- + /// Signs in against a named base rather than the active server: the add-server + /// sheet checks credentials against the server being added before anything + /// switches to it, so a wrong password leaves you where you were. + /// /// The API Server proxies login to PocketBase and relays its response /// verbatim, so the user arrives under `record` (PocketBase's name) and the /// token is PocketBase's own — the server no longer mints its own JWT. - Future<(String, AuthUser)> login(String email, String password) async { - final data = await _send("POST", "/auth/login", body: {"email": email, "password": password}); + Future<(String, AuthUser)> loginAt(String base, String email, String password) async { + final res = await http.post( + Uri.parse("$base/auth/login"), + headers: const {"Content-Type": "application/json"}, + body: jsonEncode({"email": email, "password": password}), + ); + final data = res.body.isEmpty ? null : _tryDecode(res.body); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); + } return (data["token"] as String, AuthUser.fromJson(Map.from(data["record"]))); } + Future<(String, AuthUser)> login(String email, String password) => + loginAt(serverRegistry.activeBase, email, password); + // --- cars --- Future> listCars() async { final data = await _send("GET", "/cars") as List; @@ -418,12 +427,13 @@ class ApiClient { /// the caller decodes it into whichever model it owns. Future> uploadAttachment( String path, String id, List bytes, String filename) async { - final req = http.MultipartRequest("POST", _uri("$path/$id/file")); - if (token != null) req.headers["Authorization"] = "Bearer $token"; + final target = _target(); + final req = http.MultipartRequest("POST", target.uri("$path/$id/file")) + ..headers.addAll(target.authHeaders); req.files.add(http.MultipartFile.fromBytes("file", bytes, filename: filename)); final res = await http.Response.fromStream(await req.send()); if (res.statusCode == 401) { - onUnauthorized?.call(); + onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } final data = jsonDecode(res.body); @@ -436,8 +446,8 @@ class ApiClient { /// The attachment's bytes, or null when there is no file. Never a public URL — /// the server re-checks car access on every fetch. Future?> getAttachmentBytes(String path, String id) async { - final res = await http.get(_uri("$path/$id/file"), - headers: {if (token != null) "Authorization": "Bearer $token"}); + final target = _target(); + final res = await http.get(target.uri("$path/$id/file"), headers: target.authHeaders); if (res.statusCode == 200) return res.bodyBytes; return null; } @@ -465,12 +475,13 @@ class ApiClient { // --- settings: avatar --- Future uploadAvatar(List bytes, String filename) async { - final req = http.MultipartRequest("POST", _uri("/me/avatar")); - if (token != null) req.headers["Authorization"] = "Bearer $token"; + final target = _target(); + final req = http.MultipartRequest("POST", target.uri("/me/avatar")) + ..headers.addAll(target.authHeaders); req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename)); final res = await http.Response.fromStream(await req.send()); if (res.statusCode == 401) { - onUnauthorized?.call(); + onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } final data = jsonDecode(res.body); @@ -482,8 +493,8 @@ class ApiClient { } Future?> getAvatarBytes() async { - final res = await http.get(_uri("/me/avatar"), - headers: {if (token != null) "Authorization": "Bearer $token"}); + final target = _target(); + final res = await http.get(target.uri("/me/avatar"), headers: target.authHeaders); if (res.statusCode == 200) return res.bodyBytes; return null; } @@ -507,10 +518,10 @@ class ApiClient { /// included. Returns the bytes and the filename the server named it, which is /// dated — the phone has to write the file itself, so it needs both. Future<(List, String)> exportData() async { - final res = await http.get(_uri("/me/export"), - headers: {if (token != null) "Authorization": "Bearer $token"}); + final target = _target(); + final res = await http.get(target.uri("/me/export"), headers: target.authHeaders); if (res.statusCode == 401) { - onUnauthorized?.call(); + onUnauthorized?.call(target.id); throw ApiException(401, "Session expired — please log in again."); } if (res.statusCode < 200 || res.statusCode >= 300) { diff --git a/Phone App/lib/auth.dart b/Phone App/lib/auth.dart index 7908184..fce3015 100644 --- a/Phone App/lib/auth.dart +++ b/Phone App/lib/auth.dart @@ -1,17 +1,17 @@ -import "dart:convert"; import "package:flutter/foundation.dart"; -import "package:shared_preferences/shared_preferences.dart"; import "api.dart"; +import "i18n.dart"; import "models.dart"; +import "servers.dart"; -const _tokenKey = "cc_token"; -const _userKey = "cc_user"; - -/// Holds session state and persists the token across app restarts. +/// Session state for whichever server is active. The tokens themselves are held +/// per server by [serverRegistry] — each one is a separate PocketBase, so a +/// session cannot be shared — and this service mirrors the active one, so every +/// screen goes on reading `authService.user` without knowing that more than one +/// server exists. class AuthService extends ChangeNotifier { final ApiClient api; - AuthUser? user; bool ready = false; /// In-memory (never persisted) app-lock flag. When biometric login is enabled @@ -20,10 +20,16 @@ class AuthService extends ChangeNotifier { bool locked = false; AuthService(this.api) { - api.onUnauthorized = () => logout(); + api.onUnauthorized = _onUnauthorized; + // Switching servers swaps the whole session: a different PocketBase, a + // different user record. Everything watching this service rebuilds on it. + serverRegistry.addListener(notifyListeners); } - bool get isAuthenticated => api.token != null; + /// The user of the active server's session, or null when it has none. + AuthUser? get user => serverRegistry.activeSession?.user; + + bool get isAuthenticated => serverRegistry.activeToken != null; void lock() { if (!locked && isAuthenticated) { @@ -39,26 +45,47 @@ class AuthService extends ChangeNotifier { } } + /// Sessions are read by [ServerRegistry.load]; this only flips the flag that + /// tells the app boot is done. Future loadFromStorage() async { - final prefs = await SharedPreferences.getInstance(); - final t = prefs.getString(_tokenKey); - final u = prefs.getString(_userKey); - if (t != null) { - api.token = t; - if (u != null) user = AuthUser.fromJson(jsonDecode(u)); - } ready = true; notifyListeners(); } - Future login(String email, String password) async { - final (token, u) = await api.login(email, password); - api.token = token; - user = u; + /// Logs into one server and makes it active. The login screen calls it for + /// whichever server is active; the server sheet calls it for one that isn't + /// yet, which is why the credentials are checked against that server's own + /// base before anything switches. + Future connect(String serverId, String email, String password) async { + final server = serverRegistry.byId(serverId); + if (server == null) throw ApiException(404, t("servers.unknown")); + final (token, u) = await api.loginAt(serverRegistry.baseFor(server), email, password); locked = false; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_tokenKey, token); - await prefs.setString(_userKey, jsonEncode(u.toJson())); + serverRegistry.setSession(serverId, token, u); + serverRegistry.setActive(serverId); + notifyListeners(); + } + + Future login(String email, String password) => + connect(serverRegistry.activeId, email, password); + + /// Signs out of one server without leaving the app — the server sheet's own + /// action. Dropping the active one falls back to home, the same way an expired + /// token does. + void disconnect(String serverId) { + serverRegistry.clearSession(serverId); + if (serverRegistry.activeId == serverId && serverId != kHomeServerId) { + serverRegistry.setActive(kHomeServerId); + } + notifyListeners(); + } + + /// Replaces the cached user of the active server's session — the settings + /// screen renaming the account, say. The token it was minted with stands. + void adoptUser(AuthUser updated) { + final session = serverRegistry.activeSession; + if (session == null) return; + serverRegistry.setSession(serverRegistry.activeId, session.token, updated); notifyListeners(); } @@ -69,19 +96,31 @@ class AuthService extends ChangeNotifier { Future adoptRole(UserProfile profile) async { final u = user; if (u == null || profile.role == u.role) return; - user = AuthUser(id: u.id, email: u.email, name: u.name, role: profile.role); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_userKey, jsonEncode(user!.toJson())); + adoptUser(AuthUser(id: u.id, email: u.email, name: u.name, role: profile.role)); + } + + /// Signs out everywhere. Leaving the app means leaving every server you + /// reached from it — a live token left behind on a shared phone would be worse + /// than the inconvenience of logging back in. + Future logout() async { + locked = false; + serverRegistry.clearAllSessions(); + serverRegistry.setActive(kHomeServerId); notifyListeners(); } - Future logout() async { - api.token = null; - user = null; - locked = false; - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_tokenKey); - await prefs.remove(_userKey); + /// A server rejected the token it was given. Only that server's session ends: + /// a remote one timing out shouldn't tip you out of the app, so fall back to + /// home while it is still connected, and land on the login screen only when + /// there is nothing left to fall back to. + void _onUnauthorized(String serverId) { + serverRegistry.clearSession(serverId); + if (serverRegistry.activeId == serverId && + serverId != kHomeServerId && + serverRegistry.isConnected(kHomeServerId)) { + serverRegistry.setActive(kHomeServerId); + } + if (!isAuthenticated) locked = false; notifyListeners(); } } diff --git a/Phone App/lib/main.dart b/Phone App/lib/main.dart index a60dc2b..6190b47 100644 --- a/Phone App/lib/main.dart +++ b/Phone App/lib/main.dart @@ -6,6 +6,7 @@ import "app_settings.dart"; import "auth.dart"; import "biometric.dart"; import "i18n.dart"; +import "servers.dart"; import "theme.dart"; import "screens/lock_screen.dart"; import "screens/login_screen.dart"; @@ -24,7 +25,9 @@ Future main() async { await initializeDateFormatting(); // Load the UI translation files before the first frame so t() is ready. await loadTranslations(); - await apiClient.loadServerUrl(); + // The servers the app can read from, and the session held for each — read + // before anything else so the first request knows where to go. + await serverRegistry.load(); await appSettings.loadFromStorage(); await authService.loadFromStorage(); // Prime the biometric-enabled cache; if a session survived from a previous @@ -108,7 +111,11 @@ class _CarControlAppState extends State with WidgetsBindingObserv builder: (context, _) { if (!authService.isAuthenticated) return const LoginScreen(); if (authService.locked) return const LockScreen(); - return const RootShell(); + // Keyed on the active server: switching servers swaps the garage, + // the charging page and the settings together, and a fresh shell is + // what makes every one of them re-read from the new one rather than + // keep showing the cars of the old. + return RootShell(key: ValueKey(serverRegistry.activeId)); }, ), ), diff --git a/Phone App/lib/screens/dashboard_screen.dart b/Phone App/lib/screens/dashboard_screen.dart index 2136a61..b108f77 100644 --- a/Phone App/lib/screens/dashboard_screen.dart +++ b/Phone App/lib/screens/dashboard_screen.dart @@ -4,9 +4,11 @@ import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../format.dart"; +import "../servers.dart"; import "../theme.dart"; import "car_detail_screen.dart"; import "car_form_sheet.dart"; +import "servers_sheet.dart"; class _CarRow { final Car car; @@ -89,7 +91,12 @@ class _DashboardScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(t("dashboard.eyebrow"), + // The eyebrow names the active server once there is + // more than one: two garages otherwise look identical. + Text( + serverRegistry.list.length > 1 + ? "${t("dashboard.eyebrow")} · ${serverRegistry.displayName(serverRegistry.active)}" + : t("dashboard.eyebrow"), style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context)) .copyWith(letterSpacing: 2.2)), @@ -99,6 +106,19 @@ class _DashboardScreenState extends State { ], ), ), + // Which server this garage belongs to — and the way to reach + // another one, or add the first extra. + _HeaderButton( + icon: Icons.dns_outlined, + tooltip: serverRegistry.list.length > 1 + ? t("servers.switchHint") + : t("servers.add"), + onTap: () async { + await showServerPicker(context); + if (mounted) setState(() {}); + }, + ), + const SizedBox(width: 8), _HeaderButton( icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, tooltip: dark ? t("dashboard.lightMode") : t("dashboard.darkMode"), diff --git a/Phone App/lib/screens/login_screen.dart b/Phone App/lib/screens/login_screen.dart index a209dbb..cd85a92 100644 --- a/Phone App/lib/screens/login_screen.dart +++ b/Phone App/lib/screens/login_screen.dart @@ -5,7 +5,9 @@ import "../biometric.dart"; import "../config.dart"; import "../i18n.dart"; import "../main.dart"; +import "../servers.dart"; import "../theme.dart"; +import "servers_sheet.dart"; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -34,9 +36,9 @@ class _LoginScreenState extends State { @override void initState() { super.initState(); - apiClient.serverOverride().then((v) { - if (mounted) _server.text = v; - }); + // The login form signs into whichever server is active, so Server settings + // edits that one's address — on a fresh install, the home server's. + _server.text = serverRegistry.active.url; _initBiometrics(); } @@ -68,19 +70,16 @@ class _LoginScreenState extends State { super.dispose(); } - Future _saveServer() async { - await apiClient.setServerUrl(_server.text); - final current = await apiClient.serverOverride(); - if (!mounted) return; + void _saveServer() { + final saved = serverRegistry.update(serverRegistry.activeId, url: _server.text); setState(() { - _server.text = current; + _server.text = saved?.url ?? ""; _serverSaved = t("login.savedNote"); }); } - Future _resetServer() async { - await apiClient.setServerUrl(""); - if (!mounted) return; + void _resetServer() { + serverRegistry.update(serverRegistry.activeId, url: ""); setState(() { _server.clear(); _serverSaved = t("login.resetNote"); @@ -308,13 +307,14 @@ class _LoginScreenState extends State { } } -/// Collapsible "Server settings" on the login screen: an editable API server URL -/// override (blank = use the compile-time default). Mirrors the web app. +/// Collapsible "Server settings" on the login screen: the address of the server +/// this form signs into (blank = the compile-time default), plus the way back to +/// the others once more than one has been added. Mirrors the web app. class _ServerSettings extends StatefulWidget { final TextEditingController controller; final String? savedNote; - final Future Function() onSave; - final Future Function() onReset; + final VoidCallback onSave; + final VoidCallback onReset; const _ServerSettings({ required this.controller, required this.savedNote, @@ -340,7 +340,10 @@ class _ServerSettingsState extends State<_ServerSettings> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(t("login.serverSettings"), + Text( + serverRegistry.list.length > 1 + ? "${t("login.serverSettings")} · ${serverRegistry.displayName(serverRegistry.active)}" + : t("login.serverSettings"), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)), Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey), ], @@ -378,6 +381,22 @@ class _ServerSettingsState extends State<_ServerSettings> { ), ], ), + // A session can expire on a server that isn't home, which lands here + // with that one still active — so the picker has to be reachable + // before signing in, not only from inside the app. + if (serverRegistry.list.length > 1) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () async { + await showServerPicker(context); + if (!mounted) return; + setState(() => widget.controller.text = serverRegistry.active.url); + }, + icon: const Icon(Icons.dns_outlined, size: 18), + label: Text(t("servers.title")), + ), + ), ], ], ); diff --git a/Phone App/lib/screens/servers_sheet.dart b/Phone App/lib/screens/servers_sheet.dart new file mode 100644 index 0000000..b327ef0 --- /dev/null +++ b/Phone App/lib/screens/servers_sheet.dart @@ -0,0 +1,357 @@ +import "package:flutter/material.dart"; + +import "../config.dart"; +import "../i18n.dart"; +import "../main.dart"; +import "../servers.dart"; +import "../theme.dart"; + +/// The server picker and the add/edit/sign-in sheet behind it — the phone's +/// half of the web app's rail server switcher. +/// +/// The picker names the server you are reading right now, which matters most +/// when two of them hold different cars and the screens otherwise look +/// identical, and switches between them in one tap once each has been signed +/// into. + +/// Opens the picker. With only one server known there is nothing to pick +/// between, so it goes straight to adding the second one. +Future showServerPicker(BuildContext context) { + if (serverRegistry.list.length < 2) return showServerEditor(context); + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const _ServerPickerSheet(), + ); +} + +/// Add a server, edit one, or sign into it — one sheet, because for a server you +/// have just typed in they are the same act: an address is only worth keeping +/// once something has answered at it. +Future showServerEditor(BuildContext context, {ServerEntry? server}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ServerEditorSheet(server: server), + ); +} + +/// Each server carries its own profile, so the appearance prefs come from +/// whichever one is now active. Failures are ignored: the screens behind this +/// sheet report their own load errors. +void adoptActiveServerProfile() { + apiClient.getMe().then((profile) { + appSettings.applyFromProfile(profile); + authService.adoptRole(profile); + }).catchError((_) {}); +} + +class _ServerPickerSheet extends StatefulWidget { + const _ServerPickerSheet(); + @override + State<_ServerPickerSheet> createState() => _ServerPickerSheetState(); +} + +class _ServerPickerSheetState extends State<_ServerPickerSheet> { + Future _choose(ServerEntry server) async { + if (serverRegistry.isConnected(server.id)) { + serverRegistry.setActive(server.id); + adoptActiveServerProfile(); + Navigator.pop(context); + return; + } + // No session for it yet — the same sheet that adds a server signs you in. + await showServerEditor(context, server: server); + if (mounted) setState(() {}); + } + + Future _edit(ServerEntry server) async { + await showServerEditor(context, server: server); + if (mounted) setState(() {}); + } + + Future _add() async { + await showServerEditor(context); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: DriverVault.sheetBottomInset(context), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("servers.title"), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 8), + child: Text(t("servers.switchHint"), + style: TextStyle(fontSize: 12, color: muted)), + ), + for (final server in serverRegistry.list) + ListTile( + contentPadding: EdgeInsets.zero, + // Green once this server has a session of its own; muted means + // picking it will ask for credentials rather than switch. + leading: Tooltip( + message: serverRegistry.isConnected(server.id) + ? t("servers.connected") + : t("servers.notConnected"), + child: Icon(Icons.circle, + size: 10, + color: serverRegistry.isConnected(server.id) + ? DriverVault.success + : muted.withValues(alpha: 0.4)), + ), + title: Text(serverRegistry.displayName(server), + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text(serverRegistry.baseFor(server), + style: DriverVault.mono(context, size: 11, color: muted), + overflow: TextOverflow.ellipsis), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (server.id == serverRegistry.activeId) + Icon(Icons.check, size: 18, color: Theme.of(context).colorScheme.primary), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + tooltip: t("common.edit"), + onPressed: () => _edit(server), + ), + ], + ), + onTap: () => _choose(server), + ), + const Divider(), + TextButton.icon( + onPressed: _add, + icon: const Icon(Icons.add), + label: Text(t("servers.add")), + ), + ], + ), + ), + ); + } +} + +class _ServerEditorSheet extends StatefulWidget { + // An existing server to edit/connect to, or null to add a new one. + final ServerEntry? server; + const _ServerEditorSheet({this.server}); + @override + State<_ServerEditorSheet> createState() => _ServerEditorSheetState(); +} + +class _ServerEditorSheetState extends State<_ServerEditorSheet> { + late final TextEditingController _name; + late final TextEditingController _url; + final _email = TextEditingController(); + final _password = TextEditingController(); + bool _showPassword = false; + bool _busy = false; + String? _error; + + bool get _isNew => widget.server == null; + bool get _isHome => widget.server?.isHome == true; + bool get _connected => + widget.server != null && serverRegistry.isConnected(widget.server!.id); + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.server?.name ?? ""); + _url = TextEditingController(text: widget.server?.url ?? ""); + } + + @override + void dispose() { + _name.dispose(); + _url.dispose(); + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _submit() async { + setState(() { + _busy = true; + _error = null; + }); + // Keep the server added in this attempt, so a second try after a bad + // password edits that entry instead of stacking up duplicates. + var target = widget.server; + // Read before the save: moving a connected server to a new address drops + // the token minted by the old one, and that is a save, not a sign-in — the + // credentials to replace it were never asked for. + final wasConnected = _connected; + try { + if (target == null) { + target = serverRegistry.add(name: _name.text, url: _url.text); + } else { + serverRegistry.update(target.id, name: _name.text, url: _url.text); + } + if (!wasConnected) { + await authService.connect(target.id, _email.text.trim(), _password.text); + adoptActiveServerProfile(); + } + if (mounted) Navigator.pop(context); + } catch (e) { + // A server that never answered is not worth keeping in the list. + if (_isNew && target != null) serverRegistry.remove(target.id); + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _signOut() { + authService.disconnect(widget.server!.id); + Navigator.pop(context); + } + + Future _remove() async { + final name = serverRegistry.displayName(widget.server); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(t("servers.removeConfirm", params: {"name": name})), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("common.remove"))), + ], + ), + ); + if (ok != true || !mounted) return; + serverRegistry.remove(widget.server!.id); + if (mounted) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: DriverVault.sheetBottomInset(context), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_isNew ? t("servers.addTitle") : serverRegistry.displayName(widget.server), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: t("servers.name"), + hintText: t("servers.namePlaceholder"), + border: const OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _url, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + labelText: t("servers.url"), + hintText: _isHome ? kDefaultApiBase : "https://garage.example.com", + border: const OutlineInputBorder(), + isDense: true, + helperText: _isHome ? t("servers.urlHomeHint") : t("servers.urlHint"), + helperMaxLines: 3, + ), + ), + // Credentials only while this server has no session: each server is + // its own PocketBase, so signing in happens once per server. + if (!_connected) ...[ + const SizedBox(height: 14), + const Divider(height: 1), + const SizedBox(height: 14), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: InputDecoration( + labelText: t("login.email"), + border: const OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _password, + obscureText: !_showPassword, + decoration: InputDecoration( + labelText: t("login.password"), + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: IconButton( + icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), + tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"), + onPressed: () => setState(() => _showPassword = !_showPassword), + ), + ), + onSubmitted: (_) => _submit(), + ), + ], + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy ? null : _submit, + child: Text(_busy + ? t("servers.connecting") + : _connected + ? t("common.save") + : t("servers.connect")), + ), + ), + if (_connected || (!_isNew && !_isHome)) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + if (_connected) + TextButton( + onPressed: _signOut, + style: TextButton.styleFrom(foregroundColor: muted), + child: Text(t("servers.signOut")), + ), + const Spacer(), + if (!_isNew && !_isHome) + TextButton( + onPressed: _remove, + style: TextButton.styleFrom(foregroundColor: DriverVault.danger), + child: Text(t("common.remove")), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 403ed5f..1acda86 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -263,12 +263,12 @@ class _AccountSectionState extends State<_AccountSection> { setState(() => _savingName = true); try { final updated = await apiClient.updateMe({"name": widget.nameController.text.trim()}); - authService.user = AuthUser( + authService.adoptUser(AuthUser( id: updated.id, email: updated.email, name: updated.name, role: updated.role, - ); + )); widget.snack(t("settings.account.nameSaved")); } catch (e) { widget.snack("$e"); diff --git a/Phone App/lib/servers.dart b/Phone App/lib/servers.dart new file mode 100644 index 0000000..da05aa4 --- /dev/null +++ b/Phone App/lib/servers.dart @@ -0,0 +1,291 @@ +import "dart:convert"; + +import "package:flutter/foundation.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "config.dart"; +import "i18n.dart"; +import "models.dart"; + +/// The phone can be pointed at more than one DriverVault API Server. One is the +/// "home" server — the address this build ships with, or whatever the login +/// screen's Server settings overrode it to — and any number of others are added +/// by URL, which works because an API Server a phone can reach is +/// internet-facing already. +/// +/// Each server is its own PocketBase with its own users, so a session cannot be +/// carried across: every server holds its own token under its own key. Only one +/// is active at a time and the whole app reads from it, so switching swaps the +/// garage, the charging page and the settings together. +/// +/// Mirrors the web app's servers.js, down to the storage keys — the same three +/// legacy ones are read once at boot so an upgrade doesn't sign anyone out or +/// forget the server they had pointed the app at. + +const String kHomeServerId = "home"; + +/// One server the app can read from. An empty [url] means [kDefaultApiBase], +/// which only the home entry is allowed — anything else without an address is +/// unusable. +class ServerEntry { + final String id; + String name; + String url; + + ServerEntry({required this.id, this.name = "", this.url = ""}); + + bool get isHome => id == kHomeServerId; + + Map toJson() => {"id": id, "name": name, "url": url}; + + factory ServerEntry.fromJson(Map json) => ServerEntry( + id: json["id"].toString(), + name: (json["name"] ?? "").toString(), + url: normalizeServerUrl((json["url"] ?? "").toString()), + ); +} + +/// What is held for a signed-in server: PocketBase's own token and the user it +/// was minted for. +class ServerSession { + final String token; + final AuthUser? user; + const ServerSession(this.token, this.user); +} + +/// A server address as typed. Trailing slashes go, and a bare origin gets +/// "/api" appended — every API Server route lives under it, so entering just +/// "https://garage.example.com" should work without the user knowing that. +String normalizeServerUrl(String url) { + final trimmed = url.trim().replaceAll(RegExp(r"/+$"), ""); + if (trimmed.isEmpty) return ""; + final parsed = Uri.tryParse(trimmed); + if (parsed != null && + parsed.hasScheme && + parsed.hasAuthority && + (parsed.path.isEmpty || parsed.path == "/")) { + return "${parsed.scheme}://${parsed.authority}/api"; + } + return trimmed; +} + +class ServerRegistry extends ChangeNotifier { + static const _listKey = "cc_servers"; + static const _activeKey = "cc_active_server"; + static String _sessionKey(String id) => "cc_session_$id"; + + // Pre-multi-server keys, read once by [_migrateLegacy]. + static const _legacyTokenKey = "cc_token"; + static const _legacyUserKey = "cc_user"; + static const _legacyServerKey = "cc_server_url"; + + final List list = []; + String activeId = kHomeServerId; + final Map _sessions = {}; + + // Captured once in [load] so every later write is a plain call rather than an + // await: the registry is read on the way into every request, and a switch has + // to be in effect the moment it returns. + SharedPreferences? _prefs; + + /// Reads the stored servers and their sessions. Call once in main() before + /// the first request; everything below is synchronous afterwards. + Future load() async { + final prefs = await SharedPreferences.getInstance(); + _prefs = prefs; + _migrateLegacy(); + list + ..clear() + ..addAll(_loadList()); + _sessions.clear(); + for (final server in list) { + final session = _loadSession(server.id); + if (session != null) _sessions[server.id] = session; + } + final stored = prefs.getString(_activeKey); + activeId = list.any((s) => s.id == stored) ? stored! : kHomeServerId; + } + + List _loadList() { + final raw = _prefs?.getString(_listKey); + final decoded = raw == null ? null : _tryDecode(raw); + final entries = []; + if (decoded is List) { + for (final item in decoded) { + if (item is Map && item["id"] != null) { + final entry = ServerEntry.fromJson(Map.from(item)); + if (entry.isHome || entry.url.isNotEmpty) entries.add(entry); + } + } + } + if (!entries.any((s) => s.isHome)) { + entries.insert(0, ServerEntry(id: kHomeServerId)); + } + return entries; + } + + ServerSession? _loadSession(String id) { + final raw = _prefs?.getString(_sessionKey(id)); + final decoded = raw == null ? null : _tryDecode(raw); + if (decoded is! Map || decoded["token"] == null) return null; + final user = decoded["user"]; + return ServerSession( + decoded["token"].toString(), + user is Map ? AuthUser.fromJson(Map.from(user)) : null, + ); + } + + /// Carries a pre-multi-server session onto the home server. Any override the + /// user had set in the login screen's Server settings becomes home's URL, so + /// the same server keeps answering after the upgrade. + void _migrateLegacy() { + final prefs = _prefs!; + final token = prefs.getString(_legacyTokenKey); + final legacyUrl = prefs.getString(_legacyServerKey); + if (token == null && legacyUrl == null) return; + if (token != null && prefs.getString(_sessionKey(kHomeServerId)) == null) { + final rawUser = prefs.getString(_legacyUserKey); + final user = rawUser == null ? null : _tryDecode(rawUser); + prefs.setString( + _sessionKey(kHomeServerId), + jsonEncode({"token": token, "user": user is Map ? user : null}), + ); + } + if (legacyUrl != null && legacyUrl.isNotEmpty && prefs.getString(_listKey) == null) { + prefs.setString( + _listKey, + jsonEncode([ServerEntry(id: kHomeServerId, url: normalizeServerUrl(legacyUrl)).toJson()]), + ); + } + prefs.remove(_legacyTokenKey); + prefs.remove(_legacyUserKey); + prefs.remove(_legacyServerKey); + } + + void _saveList() => + _prefs?.setString(_listKey, jsonEncode(list.map((s) => s.toJson()).toList())); + + // --- reading ------------------------------------------------------------- + + ServerEntry get active => + byId(activeId) ?? (list.isNotEmpty ? list.first : ServerEntry(id: kHomeServerId)); + + ServerEntry? byId(String id) { + for (final server in list) { + if (server.id == id) return server; + } + return null; + } + + /// The base URL requests against [server] go to. + String baseFor(ServerEntry? server) { + final url = server?.url.trim() ?? ""; + return url.isEmpty ? kDefaultApiBase : url; + } + + String get activeBase => baseFor(active); + + ServerSession? sessionFor(String id) => _sessions[id]; + + ServerSession? get activeSession => _sessions[activeId]; + + String? get activeToken => activeSession?.token; + + bool isConnected(String id) => _sessions[id] != null; + + /// What to call a server in the UI: the name it was given, else the host it + /// answers on, else — for an untouched home entry — a generic label. + String displayName(ServerEntry? server) { + if (server == null) return ""; + if (server.name.isNotEmpty) return server.name; + if (server.url.isEmpty) return t("servers.home"); + final host = Uri.tryParse(server.url)?.host ?? ""; + return host.isEmpty ? server.url : host; + } + + // --- writing ------------------------------------------------------------- + + void setSession(String id, String token, AuthUser? user) { + _sessions[id] = ServerSession(token, user); + _prefs?.setString( + _sessionKey(id), + jsonEncode({"token": token, "user": user?.toJson()}), + ); + notifyListeners(); + } + + void clearSession(String id) { + _sessions.remove(id); + _prefs?.remove(_sessionKey(id)); + notifyListeners(); + } + + void clearAllSessions() { + for (final server in list) { + _sessions.remove(server.id); + _prefs?.remove(_sessionKey(server.id)); + } + notifyListeners(); + } + + void setActive(String id) { + if (byId(id) == null || id == activeId) return; + activeId = id; + _prefs?.setString(_activeKey, id); + notifyListeners(); + } + + ServerEntry add({String name = "", String url = ""}) { + final server = ServerEntry( + id: "s${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}", + name: name.trim(), + url: normalizeServerUrl(url), + ); + list.add(server); + _saveList(); + notifyListeners(); + return server; + } + + ServerEntry? update(String id, {String? name, String? url}) { + final server = byId(id); + if (server == null) return null; + if (name != null) server.name = name.trim(); + if (url != null) { + final next = normalizeServerUrl(url); + // Moving a server to a different address invalidates the token held for + // it: it was minted by the PocketBase behind the old one. + if (next != server.url) clearSession(id); + server.url = next; + } + _saveList(); + notifyListeners(); + return server; + } + + /// Removing a server forgets its session too. Home cannot be removed — it is + /// the address the app ships with, and there would be nothing left to fall + /// back to. + void remove(String id) { + if (id == kHomeServerId) return; + clearSession(id); + list.removeWhere((s) => s.id == id); + _saveList(); + if (activeId == id) { + activeId = kHomeServerId; + _prefs?.setString(_activeKey, kHomeServerId); + } + notifyListeners(); + } + + static dynamic _tryDecode(String raw) { + try { + return jsonDecode(raw); + } catch (_) { + return null; + } + } +} + +/// App-wide singleton (mirrors biometricAuth in biometric.dart). +final serverRegistry = ServerRegistry(); diff --git a/Phone App/test/servers_test.dart b/Phone App/test/servers_test.dart new file mode 100644 index 0000000..9b43a46 --- /dev/null +++ b/Phone App/test/servers_test.dart @@ -0,0 +1,160 @@ +// The multi-server registry: which server the app reads from, and the session +// held for each. +// +// What is worth guarding is that the servers stay separate — one server's +// session is never handed to another, and dropping one never takes the rest +// with it — and that an upgrade from the single-server build carries the +// session and the address it was pointed at onto the home entry rather than +// signing the owner out. +import "dart:convert"; + +import "package:flutter_test/flutter_test.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "package:drivervault_phone/config.dart"; +import "package:drivervault_phone/i18n.dart"; +import "package:drivervault_phone/models.dart"; +import "package:drivervault_phone/servers.dart"; + +AuthUser _user(String email) => AuthUser(id: "u1", email: email, name: "Owner"); + +Future _loaded(Map stored) async { + SharedPreferences.setMockInitialValues(stored); + final registry = ServerRegistry(); + await registry.load(); + return registry; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await loadTranslations(); + }); + + group("normalizeServerUrl", () { + test("a bare origin gets the /api every route lives under", () { + expect(normalizeServerUrl("https://garage.example.com"), "https://garage.example.com/api"); + expect(normalizeServerUrl("https://garage.example.com/"), "https://garage.example.com/api"); + }); + + test("an address that already names a path is taken as given", () { + expect(normalizeServerUrl("https://example.com/api"), "https://example.com/api"); + expect(normalizeServerUrl("https://example.com/dv/api/"), "https://example.com/dv/api"); + }); + + test("blank stays blank — that is what means 'the default'", () { + expect(normalizeServerUrl(" "), ""); + }); + }); + + group("a fresh install", () { + test("knows one server, unnamed, answering on the built-in address", () async { + final registry = await _loaded({}); + expect(registry.list.length, 1); + expect(registry.activeId, kHomeServerId); + expect(registry.activeBase, kDefaultApiBase); + expect(registry.isConnected(kHomeServerId), isFalse); + expect(registry.displayName(registry.active), t("servers.home")); + }); + }); + + group("upgrading from the single-server build", () { + test("the session and the address it was pointed at land on home", () async { + final registry = await _loaded({ + "cc_token": "old-token", + "cc_user": jsonEncode(_user("owner@example.com").toJson()), + "cc_server_url": "https://garage.example.com", + }); + + expect(registry.activeId, kHomeServerId); + expect(registry.activeToken, "old-token"); + expect(registry.activeSession?.user?.email, "owner@example.com"); + expect(registry.activeBase, "https://garage.example.com/api"); + + // The keys they came from are gone, so a later boot cannot resurrect a + // session that has since been signed out of. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString("cc_token"), isNull); + expect(prefs.getString("cc_server_url"), isNull); + }); + }); + + group("several servers", () { + test("a session belongs to the one server it was minted by", () async { + final registry = await _loaded({}); + registry.setSession(kHomeServerId, "home-token", _user("home@example.com")); + final other = registry.add(name: "Work", url: "https://work.example.com"); + + expect(registry.isConnected(other.id), isFalse); + // Switching swaps the whole session, not just the address. + registry.setActive(other.id); + expect(registry.activeBase, "https://work.example.com/api"); + expect(registry.activeToken, isNull); + + registry.setSession(other.id, "work-token", _user("work@example.com")); + expect(registry.activeToken, "work-token"); + expect(registry.sessionFor(kHomeServerId)?.token, "home-token"); + }); + + test("moving one to a new address drops the token the old one minted", () async { + final registry = await _loaded({}); + final server = registry.add(url: "https://work.example.com"); + registry.setSession(server.id, "work-token", _user("work@example.com")); + + registry.update(server.id, name: "Work"); + expect(registry.isConnected(server.id), isTrue, reason: "a rename is not a move"); + + registry.update(server.id, url: "https://elsewhere.example.com"); + expect(registry.isConnected(server.id), isFalse); + }); + + test("removing the active one falls back to home, and leaves it signed in", () async { + final registry = await _loaded({}); + registry.setSession(kHomeServerId, "home-token", _user("home@example.com")); + final other = registry.add(url: "https://work.example.com"); + registry.setSession(other.id, "work-token", _user("work@example.com")); + registry.setActive(other.id); + + registry.remove(other.id); + expect(registry.activeId, kHomeServerId); + expect(registry.activeToken, "home-token"); + expect(registry.list.length, 1); + }); + + test("home cannot be removed — there would be nothing to fall back to", () async { + final registry = await _loaded({}); + registry.remove(kHomeServerId); + expect(registry.list.length, 1); + expect(registry.byId(kHomeServerId), isNotNull); + }); + + test("what they are called: the name, else the host, else the default label", () async { + final registry = await _loaded({}); + expect(registry.displayName(registry.add(name: "Home garage", url: "https://a.example.com")), + "Home garage"); + expect(registry.displayName(registry.add(url: "https://b.example.com")), "b.example.com"); + expect(registry.displayName(registry.byId(kHomeServerId)), t("servers.home")); + }); + }); + + group("what a later boot reads back", () { + test("the list, the active one and every session survive a restart", () async { + final registry = await _loaded({}); + registry.setSession(kHomeServerId, "home-token", _user("home@example.com")); + final other = registry.add(name: "Work", url: "https://work.example.com"); + registry.setSession(other.id, "work-token", _user("work@example.com")); + registry.setActive(other.id); + + final rebooted = ServerRegistry(); + await rebooted.load(); + + expect(rebooted.list.length, 2); + expect(rebooted.activeId, other.id); + expect(rebooted.activeToken, "work-token"); + expect(rebooted.activeSession?.user?.email, "work@example.com"); + expect(rebooted.displayName(rebooted.active), "Work"); + expect(rebooted.sessionFor(kHomeServerId)?.token, "home-token"); + }); + }); +} diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index 5c8a9e7..f5dbaa9 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -92,9 +92,10 @@ file, as do the units. titles are translated. The individual REST endpoint **descriptions** in the API reference table are intentionally left in English as developer reference documentation. -- **Phone App** — navigation, login, lock screen, dashboard, the full Settings - panel (including the language picker), the status/badge wording in - `lib/format.dart`, the admin users screen, and the whole car screen: its tabs, +- **Phone App** — navigation, login, lock screen, dashboard, the server picker + and its add/sign-in sheet, the full Settings panel (including the language + picker), the status/badge wording in `lib/format.dart`, the admin users + screen, and the whole car screen: its tabs, every record tile, the share and delete-car dialogs, and all of the form sheets (`car_form_sheet`, `record_form_sheets`, `attachment_field`).