From 12ec10a79772c7ffbf04e671ec456e9cd0a118cd Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:47:01 +0200 Subject: [PATCH] Phone App: more than one server, and a session for each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web app can be pointed at two DriverVault stacks and switch between them in a click. The phone had one address and one session: reaching a second garage meant retyping the API base in Server settings and signing in again, losing the first server's token on the way — the same act, undone, every time you switched back. So lib/servers.dart is the web's servers.js ported rather than reinvented, down to the storage keys: cc_servers holds the list, cc_active_server the one being read, cc_session_ the token minted by that server and no other. The two apps describe the same thing the same way, and the upgrade path falls out of it — cc_token, cc_user and cc_server_url are read once at boot and folded onto the home entry, so the build carrying this signs nobody out. Home is the address the build ships with (kDefaultApiBase, still overridable per device from the login screen) and cannot be removed: it is what a dropped session falls back to. Any other server is added by address, with /api appended if the path is left off, because a server a phone can reach is internet-facing already. The part worth reading twice is which session a rejection ends. ApiClient no longer holds a base or a token — it pins the active server's id, base and token at the moment a request goes out, so a 401 arriving after a switch clears the session of the server that actually refused it rather than whichever one is active by then. The fallback is the web's: a remote server timing out drops its own token, the app returns to home while home is still signed in, and only when nothing is left to fall back to does the login screen come back. Log out still clears every server at once, since leaving the app means leaving all of them. Switching rebuilds the shell, keyed on the active id, because record ids belong to the server that issued them — a garage, a charging page and a settings panel still holding the other server's rows would each have to be told to forget them separately. The appearance prefs come across with the profile of whoever owns the account on the server now active. Where the picker lives is the one place the phone cannot copy the web. There is no app rail here, so it became the first button in the Garage header, beside the theme toggle and log out, which is that same cluster. It names the active server once there is a choice and goes straight to adding the second when there isn't; the eyebrow reads GARAGE · Work for the reason the rail names it — two garages otherwise look identical. The login screen gets its own way in, because a remote session can expire and land you there with that server still active, and a picker reachable only from inside the app would leave nowhere to go. One judgment call inside the sheet: saving a connected server at a new address saves and stops, rather than falling through to the sign-in it now needs. The token was minted by the PocketBase behind the old address and is dropped with it, but the credentials to replace it were never asked for, so treating the save as a login would report an empty password as the error. The strings are copied out of Web App/web/src/i18n/ like the rest of the shared wording. Two are not the web's: home reads "the address this app ships with" rather than "served with this app", since the phone has no origin to be served from, and sameOrigin has no meaning here at all and was dropped. Biometric sign-in stays global. It was never per-server and replays its stored credentials against whichever server is active; making it per-server is a change of its own, and the login screen now names the server it is about to sign into. Nothing changes on the API Server. On Android there is no origin to allow, so the CORS list the web app has to satisfy to reach a second server doesn't enter into it. Verified: flutter analyze is clean and flutter test passes, 35 tests to 46. The new ones cover the registry — a bare origin gaining its /api, a fresh install knowing one unnamed server on the built-in address, the legacy keys landing on home and being cleared, two servers holding their tokens apart, a rename keeping a session where a move drops it, removing the active server falling back to a home that is still signed in, home refusing to be removed, and a restart reading the list, the active id and every session back. Not verified: none of it has been run. There is no device or emulator on this machine and no API Server to answer, so the picker, the add sheet, a real connect, the 401 fallback and the shell rebuild on a switch exist only as code the analyzer is happy with — the tests reach the registry, not a screen. No APK was built. The legacy migration was exercised against mocked SharedPreferences, which is not a phone that had the old build on it: that is the first thing to check on a device, since the failure mode is a silent sign-out. Co-Authored-By: Claude Opus 5 --- Phone App/README.md | 43 ++- Phone App/assets/i18n/da.json | 19 ++ Phone App/assets/i18n/en.json | 19 ++ Phone App/assets/i18n/pl.json | 19 ++ Phone App/lib/api.dart | 133 ++++---- Phone App/lib/auth.dart | 105 ++++-- Phone App/lib/main.dart | 11 +- Phone App/lib/screens/dashboard_screen.dart | 22 +- Phone App/lib/screens/login_screen.dart | 51 ++- Phone App/lib/screens/servers_sheet.dart | 357 ++++++++++++++++++++ Phone App/lib/screens/settings_screen.dart | 4 +- Phone App/lib/servers.dart | 291 ++++++++++++++++ Phone App/test/servers_test.dart | 160 +++++++++ TRANSLATIONS.md | 7 +- 14 files changed, 1119 insertions(+), 122 deletions(-) create mode 100644 Phone App/lib/screens/servers_sheet.dart create mode 100644 Phone App/lib/servers.dart create mode 100644 Phone App/test/servers_test.dart 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`).