App-Lifecycle: Back-Button, Resume letzte Position, sicheres Speichern

- Resume: jede Navigation wird gemerkt; nach App-Neustart (oder wenn Android
  den Prozess beim App-Wechsel beendet hat) öffnet die App wieder genau dort,
  wo der Benutzer war — inklusive offenem Protokoll
- Protokoll wird beim Verlassen der Seite (Back-Tap/Navigation) und beim
  Wechsel in den Hintergrund automatisch gesichert — auch Eingaben, die noch
  nicht per onblur gespeichert wurden, gehen nicht mehr verloren
- Hardware-Backbutton schließt einen offenen Werkzeug-Dialog, statt gleich
  die Seite zu verlassen (neue Overlay-Registry overlay.svelte.ts)
- backButton.svelte.ts: PluginListenerHandle korrekt aus @capacitor/core
  importiert (war fälschlich @capacitor/app — Svelte-Check-Fehler behoben)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 22:47:32 +02:00
parent 1a0f1dc5ca
commit 50793e4e5d
4 changed files with 88 additions and 6 deletions

View file

@ -11,8 +11,8 @@
* 3. Auf der Hauptroute: 1. Tap = Hinweis, 2. Tap binnen 1,8 s = App beenden
*/
import { App, type PluginListenerHandle } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { App } from '@capacitor/app';
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
interface BackConfig {
/** Schließt einen offenen Overlay-Zustand. true = verarbeitet, nichts weiter tun. */

32
src/lib/overlay.svelte.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* Registry offener Overlays (Dialoge, Sheets, Bottom-Sheets).
*
* Damit der Hardware-Backbutton zuerst ein offenes Overlay schließt, statt
* gleich die Seite zu verlassen, meldet jedes Overlay beim Öffnen einen
* Schließen-Callback an. Der Backbutton-Handler (backButton.svelte.ts) ruft
* `closeTopOverlay()` und navigiert nur, wenn kein Overlay offen war.
*
* Modul-Scope, kein Svelte-State Mehrfachregistrierung ist sicher.
*/
const closers: Array<() => void> = [];
/**
* Overlay anmelden, solange es geöffnet ist.
* Gibt die Abmeldefunktion zurück (im Svelte-$effect-Cleanup aufrufen).
*/
export function pushOverlay(close: () => void): () => void {
closers.push(close);
return () => {
const i = closers.lastIndexOf(close);
if (i >= 0) closers.splice(i, 1);
};
}
/** Oberstes Overlay schließen. Liefert true, wenn es eines zu schließen gab. */
export function closeTopOverlay(): boolean {
const close = closers.pop();
if (!close) return false;
close();
return true;
}

View file

@ -1,13 +1,15 @@
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { Preferences } from '@capacitor/preferences';
import { auth } from '$lib/auth.svelte';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { initDb } from '$lib/db';
import { registerBackListener, removeBackListener } from '$lib/backButton.svelte';
import { closeTopOverlay } from '$lib/overlay.svelte';
import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater';
import { initDebugLog } from '$lib/debuglog.svelte';
import Toast from '$lib/components/Toast.svelte';
@ -21,6 +23,18 @@
let updatePercent = $state(0);
const HOME = '/auftraege/';
/** Preferences-Schlüssel für die zuletzt besuchte Seite (Resume nach App-Neustart) */
const LAST_ROUTE_KEY = 'nd_last_route';
// Jede Navigation merken — damit die App nach einem Neustart (oder Wechsel
// aus einer anderen App, bei dem Android den Prozess beendet hat) wieder
// genau dort öffnet, wo der Benutzer aufgehört hat.
afterNavigate(({ to }) => {
const path = to?.url.pathname;
if (path && !path.startsWith('/login')) {
void Preferences.set({ key: LAST_ROUTE_KEY, value: path });
}
});
// Update herunterladen und Installer öffnen — Fortschritt im Banner
async function runUpdate() {
@ -44,7 +58,7 @@
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
registerBackListener({
handleOverlay: () => false,
handleOverlay: () => closeTopOverlay(),
isHomeRoute: () => {
const p = $page.url.pathname;
return p === HOME || p === '/' || p === '/login/';
@ -53,6 +67,17 @@
showExitHint: () => toast.show('Nochmal drücken zum Beenden'),
});
// Letzte Position wiederherstellen: nur beim echten Kaltstart (App öffnet
// auf "/" oder der Auftragsliste), nicht wenn gezielt woandershin navigiert
// wurde. Ungültige/gelöschte Protokolle fängt die Zielseite selbst ab.
if (auth.loggedIn) {
const last = (await Preferences.get({ key: LAST_ROUTE_KEY })).value;
const here = $page.url.pathname;
if (last && last !== here && (here === '/' || here === HOME)) {
await goto(last);
}
}
// Auf neue APK prüfen — beim Start still (kein Toast), nur Banner bei Erfolg
try {
updateInfo = await checkForUpdate();

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { App } from '@capacitor/app';
import type { PluginListenerHandle } from '@capacitor/core';
import AppHeader from '$lib/components/AppHeader.svelte';
import ToolDialog from '$lib/components/ToolDialog.svelte';
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
@ -10,6 +12,7 @@
import { addMeasurement, upsertDevice } from '$lib/protocols';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte';
import { TOOLS, getTool } from '$lib/tools';
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
@ -20,6 +23,8 @@
let activeDevice = $state<Device | undefined>(undefined);
let saving = $state(false);
let appStateListener: PluginListenerHandle | null = null;
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
@ -27,7 +32,7 @@
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
onMount(async () => {
const uuid = $page.params.id;
const uuid = $page.params.id ?? '';
const p = await getProtocol(uuid);
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
@ -35,6 +40,19 @@
return;
}
protocol = p;
// App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) →
// sofort sichern, bevor Android den Prozess evtl. beendet.
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) void persist();
});
});
onDestroy(() => {
// Beim Verlassen der Seite (Back-Tap, Navigation) final sichern — fängt
// auch Eingaben ab, die noch nicht per onblur gespeichert wurden.
void persist();
appStateListener?.remove();
});
/** Protokoll als geändert markieren und lokal speichern */
@ -45,6 +63,13 @@
await sync.refreshPending();
}
// Offenen Werkzeug-Dialog beim Hardware-Backbutton schließen, statt
// gleich die Seite zu verlassen.
$effect(() => {
if (!activeTool) return;
return pushOverlay(() => (activeTool = null));
});
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
function icon(name: string) {
const pascal = name