No description
  • Python 51.7%
  • HTML 38.4%
  • Kotlin 8.1%
  • CSS 1.7%
Find a file
2026-10-25 13:03:32 +01:00
app Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
gradle Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
server Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
tests Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
.gitignore Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
build.gradle.kts Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
COPYRIGHT Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
gradle.properties Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
gradlew Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
gradlew.bat Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
LICENSE Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
README.md Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00
settings.gradle.kts Stand vom 2026-10-25 2026-10-25 13:03:32 +01:00

Merkliste

Listen- und Einkaufs-App mit Gedächtnis — Ersatz für FastShopping. Android-WebView-App wie Finanzplan/Ruderdaten: die gesamte Oberfläche und Logik steckt in app/src/main/assets/app.html, die Kotlin-Schale speichert nur (filesDir/data.json) und regelt Backup-Export/-Import.

Stufe 1 – 3 sind umgesetzt und im Betrieb, Abgleich vom Telefon getestet. Dazu die Rezept-Leseansicht (v1.2.0) und die Aufgaben-Ausbaustufe (v1.3.0). Stufe 4 des Plans (Rezept-Import per URL, Kategorien, Wochenplanung) steht noch aus.

Was drin ist

  • Drei Listenarten — Einkauf, Packliste, ToDo. Anlegen, umbenennen, archivieren, löschen, alles mit Rückgängig-Band.
  • Listenansicht: Eingabefeld mit Autovervollständigung aus dem Katalog, Abgehaktes rutscht ausgegraut nach unten, Menge als Freitext (Mehl – 500 g wird beim Tippen automatisch getrennt), Sortiermodus (↕), „Abgehaktes entfernen", „Liste zurücksetzen" (der Packlisten-Fall).
  • Katalog wächst automatisch: Was auf einer Einkaufsliste abgehakt wird, landet dort mit Kaufzahl und den letzten 10 Kauf-Zeitpunkten. Haken wieder weg = Buchung wieder weg.
  • Vorschläge: „Häufig" (Kaufzahl, nach Aktualität gewichtet, Halbwertszeit 60 Tage) und „Länger nicht mehr gekauft" (ab 3 Käufen, Median-Abstand × 1,2).
  • Vorlagen & Rezepte — Rezept (Zutaten + Zubereitung + Portionen), Packliste, Baustein. Suche über Name/Zutat/Zubereitung, Tag-Filter. „Hinzufügen aus …" mit Mehrfachauswahl; doppelte Namen werden bewusst nicht verrechnet, sondern stehen nebeneinander.
  • Rezepte lesen statt versehentlich ändern: Ein Rezept öffnet sich als Leseansicht (Zutaten, nummerierte Schritte, Tags, Quelle) mit „🍳 Kochmodus" und „🛒 Zutaten einkaufen". Das Formular kommt erst über ✏️ — im Webclient entsprechend über ?bearbeiten=1.
  • Kochmodus (🍳 im Rezept): groß lesbar, Bildschirm bleibt an, Zutaten und Schritte lassen sich beim Kochen abhaken (flüchtig, nicht im Rezept).
  • Gemeinsames Kochbuch (v1.11.0, Server 1.13.0): Die Rezepte aller Konten stehen einander offen — nur zum Lesen. Zu finden über 👥 auf dem Vorlagen-Bildschirm oder im Hauptmenü. Von dort lassen sich die Zutaten direkt einkaufen (ohne das Rezept zu übernehmen), oder man übernimmt es und bekommt eine eigene Kopie mit Herkunftsvermerk; ändern geht nur an der eigenen. Ein eigenes Rezept lässt sich mit einem Tipp aus dem Kochbuch nehmen („nur für dich") — sichtbar ist der Standard. Der Bestand wird geblättert, nicht abgeglichen: fremde Rezepte kommen nie ins Gerät.
  • Listen teilen (v1.10.0, Server 1.11.0): Eine eigene Liste lässt sich einzelnen Konten zum Mitpflegen geben — Listenmenü → „Mit jemandem teilen". Wer sie bekommt, darf eintragen, ändern und abhaken; löschen und weitergeben kann nur der Eigentümer, für alle anderen heißt der Menüpunkt „Nicht mehr mitmachen". Die Liste bleibt dabei im Konto des Eigentümers, es entsteht keine Kopie. Der Katalog bleibt persönlich: wer in einer fremden Liste abhakt, füttert seine eigenen Vorschläge, nicht die des anderen. Rezepte sind davon unberührt — die sind nur zum Lesen (Etappe B).
  • Aufgaben (ToDo-Listen): Fälligkeit mit Uhrzeit, Priorität (hoch / normal / niedrig), Wiederholung (täglich / wöchentlich / monatlich) und Erinnerung als echte Android-Benachrichtigung. Offene Aufgaben stehen nach Fälligkeit gebündelt (Überfällig / Heute / Morgen / Diese Woche / Später / Ohne Datum). Die Schnelleingabe versteht Zusätze: Müll rausbringen morgen 18:00 ! (heute/morgen/übermorgen, Wochentag, 31.12., Uhrzeit, ! = hoch, ? = niedrig). Eine wiederkehrende Aufgabe rückt beim Abhaken auf ihren nächsten Termin, statt zu verschwinden.
  • Erinnerungen prüfen (Einstellungen → Erinnerungen): sagt, warum eine Erinnerung ausgeblieben ist. Die Ursache liegt immer im System, nie in der App — Benachrichtigungen abgelehnt, Kanal „Aufgaben" stummgeschaltet, keine genauen Wecker, Akku-Optimierung, „Nicht stören"; jeder Punkt führt direkt zum richtigen Android-Dialog. Dazu ein Probe-Wecker („Testerinnerung in 15 Sekunden"), der denselben Weg geht wie eine echte Erinnerung. Wichtig: ab targetSdk 34 erteilt Android SCHEDULE_EXACT_ALARM nicht mehr von allein — ohne genaue Wecker schiebt der Standby-Eimer eine Meldung um bis zu 24 Stunden. Deshalb hat das Manifest seit 1.9.0 USE_EXACT_ALARM (gilt ab Android 13 ohne Nachfrage und ist genau für Erinnerungs-Apps gedacht).
  • Aufgaben im Kalender (Einstellungen → Kalender): einen Zielkalender wählen, dann trägt die App jede Aufgabe, die dafür angehakt ist (📅 im Aufgaben-Dialog), dort ein — und nimmt sie wieder heraus, sobald sie erledigt, verschoben oder gelöscht ist. Am saubersten mit einem eigenen Kalender nur dafür, etwa einem über DAVx⁵ eingebundenen CalDAV-Kalender „Aufgaben"; Löschungen wandern dann von allein auf den Server. Die Kalender-Rechte fragt die App erst beim Einschalten; ohne sie bleibt alles wie vorher.
  • Papierkorb (Menü ☰, am Rechner in der Kopfzeile): Gelöschtes bleibt 90 Tage liegen und lässt sich zurückholen — auch das, was ein anderes Gerät gelöscht hat. Ein Eintrag aus einer gelöschten Liste holt die Liste mit. Erledigte Listen gehören eher ins Archiv (Listenmenü → „Ins Archiv legen"), das räumt den Start auf, ohne etwas wegzuwerfen. Mit dem letzten Haken einer Liste bietet die App das Archivieren von selbst an — ein Band unten, zwölf Sekunden lang; am Rechner steht der Vorschlag unter der fertigen Liste.
  • Backup als JSON-Datei; beim Einspielen wahlweise ersetzen oder zusammenführen (dieselbe Regel wie beim Abgleich).
  • Abgleich mit dem eigenen Server (Einstellungen → Abgleich): Adresse und Token eintragen, danach läuft es von allein. Ohne Eintrag bleibt alles auf dem Gerät. Serverteil und Rechner-Oberfläche: server/. Alles neu abgleichen schickt und holt einmal den kompletten Bestand — nötig, wenn Gerät und Server sich über einen Datensatz uneinig sind, den seither niemand mehr angefasst hat (im normalen Abgleich kommt der nie wieder zur Sprache). Nach jedem App-Update passiert das einmal von selbst.

Datenmodell

Ein JSON-Dokument mit listen[], vorlagen[], katalog[]. Sync-Vorbereitung von Anfang an: jede Entität hat UUID, updated_at, device_id; Löschen setzt deleted_at (Grabstein, wird nach 90 Tagen beim Start aufgeräumt) — solange steht der Eintrag im Papierkorb.

Fensterschale: Tastatur und Systemleisten (v1.6.4)

Seit targetSdk 35 (Android 15) zeichnet die App zwangsweise edge-to-edge unter Status- und Navigationsleiste, und android:windowSoftInputMode="adjustResize" verkleinert das Fenster nicht mehr, wenn die Tastatur kommt. MainActivity.bauLayout() regelt das deshalb selbst:

  • WebView steckt in einem FrameLayout, WindowCompat.setDecorFitsSystemWindows(window, false) — damit gilt auf allen Android-Versionen derselbe Weg.
  • Der Insets-Listener auf der Wurzel setzt systemBars() und ime() als Ränder (layoutParams.topMargin usw., unten maxOf(Tastatur, Navigationsleiste)) auf die WebView und gibt WindowInsetsCompat.CONSUMED zurück. Der untere Rand hält auch den „+ Neue Liste"-Knopf von den Navigationssymbolen frei.
  • Darüber liegt ein View-Streifen in Markenfarbe mit Höhe = oberer Inset, sonst wirkt die Statusleiste abgeschnitten.

Wichtig, nicht zurückdrehen: Der Abstand muss über die Ränder laufen. Padding auf die WebView (so der erste Anlauf in v1.6.2) beschneidet den Inhalt nur — die Seite bricht nicht neu um, 100vh/position:fixed/scrollbare Blätter bleiben hinter der Tastatur, und etwa die Server-Adresse musste man blind eintippen.

Die Seite zieht mit: unter der Bridge setzt sie --safe-t/--safe-b auf 0 (sonst zählt der Abstand doppelt), rückt bei focusin das beschriebene Feld per feldInDenBlick() in den Blick und legt body.gesperrt (position:fixed mit gemerkter Scrollposition) an, solange ein Blatt offen ist — sonst scrollt die Seite unter dem offenen Menü weg. go() schließt beim Seitenwechsel ein offenes Blatt mit.

Dunkelmodus (v1.8.0)

Einstellungen → Darstellung → Erscheinungsbild: Folgt dem System · Immer hell · Immer dunkel. Drei Stellen greifen ineinander:

  • Seite: ein kurzes Skript im <head> löst die Wahl auf und setzt data-theme="hell|dunkel" am <html> — noch vor dem ersten Malen, sonst blitzt die helle Seite auf. Das CSS kennt nur noch dieses Attribut. Das Blau der Marke bekommt im Dunkeln ein aufgehelltes Geschwister --accent, weil es als Schrift auf fast schwarzem Grund untergeht.
  • Schale: ohne WebSettingsCompat.setAlgorithmicDarkeningAllowed(…, true) meldet die WebView seit targetSdk 33+ immer „hell" — der Dunkelmodus wäre auf dem Gerät nie zu sehen. <meta name="color-scheme" content="light dark"> sagt Android zugleich, dass die Seite ihren eigenen mitbringt und nichts nachgedunkelt werden muss.
  • Merken: Android.setTheme(pref) legt die Wahl in den SharedPreferences ab; MainActivity.onCreate setzt daraus setDefaultNightMode, damit Fensterhintergrund und Systemleisten passen. Zur Laufzeit wird nur der Fensterhintergrund umgefärbt — ein Moduswechsel würde die Activity neu bauen und die offene Liste mitreißen.

ml_theme liegt in localStorage (Gerätesache, nicht im Abgleich).

Installieren

APK per adb install oder direkt auf dem Gerät. Beim Sprung von v1.0.x auf v1.1.x einmal deinstallieren statt zu aktualisieren: v1.1.0 hat die INTERNET-Berechtigung nachgerüstet, und nachgerüstete Berechtigungen greifen bei einem Update über eine bestehende Installation nicht — der Abgleich meldet dann nur „Kein Kontakt zum Server". Vorher über Einstellungen → Backup sichern, die Daten überleben das Deinstallieren nicht. Innerhalb von v1.1.x sind normale Updates in Ordnung.

Bauen und testen

JAVA_HOME=/usr/lib/jvm/java-17-temurin-jdk ./gradlew assembleDebug
# → app/build/outputs/apk/debug/app-debug.apk

python3 tests/test_merkliste.py     # 44 Durchklick-Checks (Playwright, ohne Gerät)
python3 tests/test_gruppen.py       # Unterpunkte
python3 tests/test_vorrat.py        # Katalog-Vorrat „hab ich immer da"
python3 tests/test_zutaten.py       # Zutaten mit Menge, Zwischenüberschriften
python3 tests/test_theme.py         # Dunkelmodus (System/Hell/Dunkel)
python3 tests/test_sync.py          # 26 Checks: App gegen echten Server
python3 tests/test_teilen_e2e.py    # geteilte Listen, zwei Konten, zwei Geräte

Die Web-App läuft ohne Android: app.html direkt im Browser öffnen, dann wird statt der Bridge localStorage benutzt.

Release: assembleRelease bauen, das APK mit dem eigenen Schlüssel signieren (zipalign + apksigner). versionCode dabei immer hochzählen, sonst bietet F-Droid kein Update an.