Compare commits
26 Commits
fix/connec
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
849e4080d6 | ||
|
|
59c417cc4c | ||
|
|
90f1f48810 | ||
|
|
e2bce099f3 | ||
|
|
661d9e0992 | ||
|
|
3e946edafb | ||
|
|
4a621b622b | ||
|
|
49810ff6f1 | ||
|
|
d045d4d3db | ||
|
|
eaac5a0775 | ||
|
|
68584461b3 | ||
|
|
881c0fd0fa | ||
|
|
1da1a63566 | ||
|
|
96c819b154 | ||
|
|
539987f2ed | ||
|
|
900dad76fe | ||
|
|
538a705def | ||
|
|
3462f93f25 | ||
|
|
bdfc0bf060 | ||
|
|
df37d2a47c | ||
|
|
bf7a74ec30 | ||
|
|
07607fc095 | ||
|
|
40d7c83c84 | ||
|
|
e9e4b87853 | ||
|
|
45f528ea0e | ||
|
|
cb1bc46405 |
220
CHANGELOG.de.md
220
CHANGELOG.de.md
@@ -8,6 +8,222 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-02-10
|
||||||
|
|
||||||
|
### 🚨 CRITICAL BUGFIX (Tag neu erstellt)
|
||||||
|
|
||||||
|
**R8/ProGuard Obfuscation Fix - Verhindert Datenverlust**
|
||||||
|
- 🔧 **KRITISCH:** Falscher ProGuard-Klassenpfad für `Note$Companion$NoteRaw` korrigiert
|
||||||
|
- Original v1.8.0 hatte spezifische `-keep` Regeln die nicht griffen
|
||||||
|
- R8 obfuskierte alle NoteRaw-Felder (id→a, title→b, ...)
|
||||||
|
- Gson konnte JSON nicht mehr parsen → **ALLE Notizen erschienen verschwunden**
|
||||||
|
- Zurück zur sicheren breiten Regel: `-keep class dev.dettmer.simplenotes.** { *; }`
|
||||||
|
- 🛡️ Safety-Guards in `detectServerDeletions()` hinzugefügt
|
||||||
|
- Verhindert Massenlöschung bei leeren `serverNoteIds` (Netzwerkfehler)
|
||||||
|
- Abort wenn ALLE lokalen Notizen als gelöscht erkannt würden
|
||||||
|
- ✅ Notizen waren nie wirklich verloren (JSON-Dateien intakt auf Disk + Server)
|
||||||
|
- ✅ Downgrade auf v1.7.2 holte alle Notizen zurück
|
||||||
|
|
||||||
|
**⚠️ Falls du v1.8.0 erste Version installiert hattest:** Deine Notizen sind sicher! Einfach updaten.
|
||||||
|
|
||||||
|
### 🎉 Major: Widgets, Sortierung & Erweiterte Sync-Features
|
||||||
|
|
||||||
|
Komplettes Widget-System mit interaktiven Checklisten, Notiz-Sortierung und umfangreiche Sync-Verbesserungen!
|
||||||
|
|
||||||
|
### 🆕 Homescreen-Widgets
|
||||||
|
|
||||||
|
**Vollständiges Jetpack Glance Widget-Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f))
|
||||||
|
- 5 responsive Größenklassen (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL)
|
||||||
|
- Interaktive Checklist-Checkboxen die sofort zum Server synchronisieren
|
||||||
|
- Material You Dynamic Colors mit konfigurierbarer Hintergrund-Transparenz (0-100%)
|
||||||
|
- Widget-Sperre-Toggle zum Verhindern versehentlicher Änderungen
|
||||||
|
- Read-Only-Modus mit permanenter Options-Leiste für gesperrte Widgets
|
||||||
|
- Widget-Konfigurations-Activity mit Notiz-Auswahl und Einstellungen
|
||||||
|
- Auto-Refresh nach Sync-Abschluss
|
||||||
|
- Tippen auf Inhalt öffnet Editor (entsperrt) oder zeigt Optionen (gesperrt)
|
||||||
|
- Vollständige Resource-Cleanup-Fixes für Connection Leaks
|
||||||
|
|
||||||
|
**Widget State Management:**
|
||||||
|
- NoteWidgetState Keys für pro-Instanz-Persistierung via DataStore
|
||||||
|
- Fünf Top-Level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config)
|
||||||
|
- Type-Safe Parameter-Übergabe mit NoteWidgetActionKeys
|
||||||
|
|
||||||
|
### 📊 Notiz- & Checklisten-Sortierung
|
||||||
|
|
||||||
|
**Notiz-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b))
|
||||||
|
- Sortieren nach: Aktualisiert (neueste/älteste), Erstellt, Titel (A-Z/Z-A), Typ
|
||||||
|
- Persistente Sortierungs-Präferenzen (gespeichert in SharedPreferences)
|
||||||
|
- Sortierungs-Dialog im Hauptbildschirm mit Richtungs-Toggle
|
||||||
|
- Kombinierte sortedNotes StateFlow im MainViewModel
|
||||||
|
|
||||||
|
**Checklisten-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7))
|
||||||
|
- Sortieren nach: Manual, Alphabetisch, Offen zuerst, Erledigt zuletzt
|
||||||
|
- Visueller Separator zwischen offenen/erledigten Items mit Anzahl-Anzeige
|
||||||
|
- Auto-Sort bei Item-Toggle und Neuordnung
|
||||||
|
- Drag nur innerhalb gleicher Gruppe (offen/erledigt)
|
||||||
|
- Sanfte Fade/Slide-Animationen für Item-Übergänge
|
||||||
|
- Unit-getestet mit 9 Testfällen für Sortierungs-Logik-Validierung
|
||||||
|
|
||||||
|
### 🔄 Sync-Verbesserungen
|
||||||
|
|
||||||
|
**Server-Löschungs-Erkennung** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e))
|
||||||
|
- Neuer `DELETED_ON_SERVER` Sync-Status für Multi-Device-Szenarien
|
||||||
|
- Erkennt wenn Notizen auf anderen Clients gelöscht wurden
|
||||||
|
- Zero Performance-Impact (nutzt existierende PROPFIND-Daten)
|
||||||
|
- Löschungs-Anzahl im Sync-Banner: "3 synchronisiert · 2 auf Server gelöscht"
|
||||||
|
- Bearbeitete gelöschte Notizen werden automatisch zum Server hochgeladen (Status → PENDING)
|
||||||
|
|
||||||
|
**Sync-Status-Legende** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc))
|
||||||
|
- Hilfe-Button (?) in Hauptbildschirm TopAppBar
|
||||||
|
- Dialog erklärt alle 5 Sync-Status-Icons mit Beschreibungen
|
||||||
|
- Nur sichtbar wenn Sync konfiguriert ist
|
||||||
|
|
||||||
|
**Live-Sync-Fortschritts-UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a))
|
||||||
|
- Echtzeit-Phasen-Indikatoren: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN
|
||||||
|
- Upload-Fortschritt zeigt x/y Counter (bekannte Gesamtzahl)
|
||||||
|
- Download-Fortschritt zeigt Anzahl (unbekannte Gesamtzahl)
|
||||||
|
- Einheitliches SyncProgressBanner (ersetzt Dual-System)
|
||||||
|
- Auto-Hide: COMPLETED (2s), ERROR (4s)
|
||||||
|
- Keine irreführenden Counter wenn nichts zu synchronisieren ist
|
||||||
|
- Stiller Auto-Sync bleibt still, Fehler werden immer angezeigt
|
||||||
|
|
||||||
|
**Parallele Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf))
|
||||||
|
- Konfigurierbare gleichzeitige Downloads (Standard: 3 simultan)
|
||||||
|
- Kotlin Coroutines async/awaitAll Pattern
|
||||||
|
- Individuelle Download-Timeout-Behandlung
|
||||||
|
- Graceful sequentieller Fallback bei gleichzeitigen Fehlern
|
||||||
|
- Optimierte Netzwerk-Auslastung für schnelleren Sync
|
||||||
|
|
||||||
|
### ✨ UX-Verbesserungen
|
||||||
|
|
||||||
|
**Checklisten-Verbesserungen:**
|
||||||
|
- Überlauf-Verlauf für lange Text-Items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93))
|
||||||
|
- Auto-Expand bei Fokus, Collapse auf 5 Zeilen bei Fokus-Verlust
|
||||||
|
- Drag & Drop Flackern-Fix mit Straddle-Target-Center-Erkennung ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705))
|
||||||
|
- Adjacency-Filter verhindert Item-Sprünge bei schnellem Drag
|
||||||
|
- Race-Condition-Fix für Scroll + Move-Operationen
|
||||||
|
|
||||||
|
**Einstellungs-UI-Polish:**
|
||||||
|
- Sanfter Sprachwechsel ohne Activity-Recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd))
|
||||||
|
- Raster-Ansicht als Standard für Neu-Installationen ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446))
|
||||||
|
- Sync-Einstellungen umstrukturiert in klare Sektionen: Auslöser & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0))
|
||||||
|
- Changelog-Link zum About-Screen hinzugefügt ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff))
|
||||||
|
|
||||||
|
**Post-Update Changelog-Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0))
|
||||||
|
- Zeigt lokalisierten Changelog beim ersten Start nach Update
|
||||||
|
- Material 3 ModalBottomSheet mit Slide-up-Animation
|
||||||
|
- Lädt F-Droid Changelogs via Assets (Single Source of Truth)
|
||||||
|
- Einmalige Anzeige pro versionCode (gespeichert in SharedPreferences)
|
||||||
|
- Klickbarer GitHub-Link für vollständigen Changelog
|
||||||
|
- Durch Button oder Swipe-Geste schließbar
|
||||||
|
- Test-Modus in Debug-Einstellungen mit Reset-Option
|
||||||
|
|
||||||
|
**Backup-Einstellungs-Verbesserungen** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed))
|
||||||
|
- Neue BackupProgressCard mit LinearProgressIndicator
|
||||||
|
- 3-Phasen-Status-System: In Progress → Abschluss → Löschen
|
||||||
|
- Erfolgs-Status für 2s angezeigt, Fehler für 3s
|
||||||
|
- Redundante Toast-Nachrichten entfernt
|
||||||
|
- Buttons bleiben sichtbar und deaktiviert während Operationen
|
||||||
|
- Exception-Logging für besseres Error-Tracking
|
||||||
|
|
||||||
|
### 🐛 Fehlerbehebungen
|
||||||
|
|
||||||
|
**Widget-Text-Anzeige** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d))
|
||||||
|
- Text-Notizen zeigen nicht mehr nur 3 Zeilen in Widgets
|
||||||
|
- Von Absatz-basiert zu Zeilen-basiertem Rendering geändert
|
||||||
|
- LazyColumn scrollt jetzt korrekt durch gesamten Inhalt
|
||||||
|
- Leere Zeilen als 8dp Spacer beibehalten
|
||||||
|
- Vorschau-Limits erhöht: compact 100→120, full 200→300 Zeichen
|
||||||
|
|
||||||
|
### 🔧 Code-Qualität
|
||||||
|
|
||||||
|
**Detekt-Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63))
|
||||||
|
- Alle 22 Detekt-Warnungen behoben
|
||||||
|
- 7 ungenutzte Imports entfernt
|
||||||
|
- Konstanten für 5 Magic Numbers definiert
|
||||||
|
- State-Reads mit derivedStateOf optimiert
|
||||||
|
- Build: 0 Lint-Fehler + 0 Detekt-Warnungen
|
||||||
|
|
||||||
|
### 📚 Dokumentation
|
||||||
|
|
||||||
|
- Vollständige Implementierungs-Pläne für alle 23 v1.8.0 Features
|
||||||
|
- Widget-System-Architektur und State-Management-Docs
|
||||||
|
- Sortierungs-Logik Unit-Tests mit Edge-Case-Coverage
|
||||||
|
- F-Droid Changelogs (Englisch + Deutsch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-02-04
|
||||||
|
|
||||||
|
### 🐛 Kritische Fehlerbehebungen
|
||||||
|
|
||||||
|
#### JSON/Markdown Timestamp-Synchronisation
|
||||||
|
|
||||||
|
**Problem:** Externe Editoren (Obsidian, Typora, VS Code, eigene Editoren) aktualisieren Markdown-Inhalt, aber nicht den YAML `updated:` Timestamp, wodurch die Android-App Änderungen überspringt.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Server-Datei Änderungszeit (`mtime`) wird jetzt als Source of Truth statt YAML-Timestamp verwendet
|
||||||
|
- Inhaltsänderungen werden via Hash-Vergleich erkannt
|
||||||
|
- Notizen nach Markdown-Import als `PENDING` markiert → JSON automatisch beim nächsten Sync hochgeladen
|
||||||
|
- Behebt Sortierungsprobleme nach externen Bearbeitungen
|
||||||
|
|
||||||
|
#### SyncStatus auf Server immer PENDING
|
||||||
|
|
||||||
|
**Problem:** Alle JSON-Dateien auf dem Server enthielten `"syncStatus": "PENDING"` auch nach erfolgreichem Sync, was externe Clients verwirrte.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Status wird jetzt auf `SYNCED` gesetzt **vor** JSON-Serialisierung
|
||||||
|
- Server- und lokale Kopien sind jetzt konsistent
|
||||||
|
- Externe Web/Tauri-Editoren können Sync-Status korrekt interpretieren
|
||||||
|
|
||||||
|
#### Deletion Tracker Race Condition
|
||||||
|
|
||||||
|
**Problem:** Batch-Löschungen konnten Lösch-Einträge verlieren durch konkurrierenden Dateizugriff.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Mutex-basierte Synchronisation für Deletion Tracking
|
||||||
|
- Neue `trackDeletionSafe()` Funktion verhindert Race Conditions
|
||||||
|
- Garantiert Zombie-Note-Prevention auch bei schnellen Mehrfach-Löschungen
|
||||||
|
|
||||||
|
#### ISO8601 Timezone-Parsing
|
||||||
|
|
||||||
|
**Problem:** Markdown-Importe schlugen fehl mit Timezone-Offsets wie `+01:00` oder `-05:00`.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Multi-Format ISO8601 Parser mit Fallback-Kette
|
||||||
|
- Unterstützt UTC (Z), Timezone-Offsets (+01:00, +0100) und Millisekunden
|
||||||
|
- Kompatibel mit Obsidian, Typora, VS Code Timestamps
|
||||||
|
|
||||||
|
### ⚡ Performance-Verbesserungen
|
||||||
|
|
||||||
|
#### E-Tag Batch Caching
|
||||||
|
|
||||||
|
- E-Tags werden jetzt in einer einzigen Batch-Operation geschrieben statt N einzelner Schreibvorgänge
|
||||||
|
- Performance-Gewinn: ~50-100ms pro Sync mit mehreren Notizen
|
||||||
|
- Reduzierte Disk-I/O-Operationen
|
||||||
|
|
||||||
|
#### Memory Leak Prevention
|
||||||
|
|
||||||
|
- `SafeSardineWrapper` implementiert jetzt `Closeable` für explizites Resource-Cleanup
|
||||||
|
- HTTP Connection Pool wird nach Sync korrekt aufgeräumt
|
||||||
|
- Verhindert Socket-Exhaustion bei häufigen Syncs
|
||||||
|
|
||||||
|
### 🔧 Technische Details
|
||||||
|
|
||||||
|
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` für thread-sicheres Deletion Tracking
|
||||||
|
- **IMPL_002:** Pattern-basierter ISO8601 Parser mit 8 Format-Varianten
|
||||||
|
- **IMPL_003:** Connection Pool Eviction + Dispatcher Shutdown in `close()`
|
||||||
|
- **IMPL_004:** Batch `SharedPreferences.Editor` Updates
|
||||||
|
- **IMPL_014:** Server `mtime` Parameter in `Note.fromMarkdown()`
|
||||||
|
- **IMPL_015:** `syncStatus` vor `toJson()` Aufruf gesetzt
|
||||||
|
|
||||||
|
### 📚 Dokumentation
|
||||||
|
|
||||||
|
- External Editor Specification für Web/Tauri-Editor-Entwickler
|
||||||
|
- Detaillierte Implementierungs-Dokumentation für alle Bugfixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.1] - 2026-02-02
|
## [1.7.1] - 2026-02-02
|
||||||
|
|
||||||
### 🐛 Kritische Fehlerbehebungen
|
### 🐛 Kritische Fehlerbehebungen
|
||||||
@@ -569,8 +785,8 @@ Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist j
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||||
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
- Complete sync architecture documentation
|
||||||
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
- Desktop integration analysis
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
220
CHANGELOG.md
220
CHANGELOG.md
@@ -8,6 +8,222 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-02-10
|
||||||
|
|
||||||
|
### 🚨 CRITICAL BUGFIX (Tag recreated)
|
||||||
|
|
||||||
|
**R8/ProGuard Obfuscation Fix - Prevents Data Loss**
|
||||||
|
- 🔧 **CRITICAL:** Fixed incorrect ProGuard class path for `Note$Companion$NoteRaw`
|
||||||
|
- Original v1.8.0 had specific `-keep` rules that didn't match
|
||||||
|
- R8 obfuscated all NoteRaw fields (id→a, title→b, ...)
|
||||||
|
- Gson couldn't parse JSON anymore → **ALL notes appeared lost**
|
||||||
|
- Reverted to safe broad rule: `-keep class dev.dettmer.simplenotes.** { *; }`
|
||||||
|
- 🛡️ Added safety-guards in `detectServerDeletions()`
|
||||||
|
- Prevents mass deletion when `serverNoteIds` is empty (network errors)
|
||||||
|
- Aborts if ALL local notes would be marked as deleted
|
||||||
|
- ✅ Notes were never actually lost (JSON files intact on disk + server)
|
||||||
|
- ✅ Downgrade to v1.7.2 restored all notes
|
||||||
|
|
||||||
|
**⚠️ If you installed original v1.8.0:** Your notes are safe! Just update.
|
||||||
|
|
||||||
|
### 🎉 Major: Widgets, Sorting & Advanced Sync
|
||||||
|
|
||||||
|
Complete widget system with interactive checklists, note sorting, and major sync improvements!
|
||||||
|
|
||||||
|
### 🆕 Homescreen Widgets
|
||||||
|
|
||||||
|
**Full Jetpack Glance Widget Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f))
|
||||||
|
- 5 responsive size classes (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL)
|
||||||
|
- Interactive checklist checkboxes that sync immediately to server
|
||||||
|
- Material You dynamic colors with configurable background opacity (0-100%)
|
||||||
|
- Lock widget toggle to prevent accidental edits
|
||||||
|
- Read-only mode with permanent options bar for locked widgets
|
||||||
|
- Widget configuration activity with note selection and settings
|
||||||
|
- Auto-refresh after sync completion
|
||||||
|
- Tap content to open editor (unlocked) or show options (locked)
|
||||||
|
- Complete resource cleanup fixes for connection leaks
|
||||||
|
|
||||||
|
**Widget State Management:**
|
||||||
|
- NoteWidgetState keys for per-instance persistence via DataStore
|
||||||
|
- Five top-level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config)
|
||||||
|
- Type-safe parameter passing with NoteWidgetActionKeys
|
||||||
|
|
||||||
|
### 📊 Note & Checklist Sorting
|
||||||
|
|
||||||
|
**Note Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b))
|
||||||
|
- Sort by: Updated (newest/oldest), Created, Title (A-Z/Z-A), Type
|
||||||
|
- Persistent sort preferences (saved in SharedPreferences)
|
||||||
|
- Sort dialog in main screen with direction toggle
|
||||||
|
- Combined sortedNotes StateFlow in MainViewModel
|
||||||
|
|
||||||
|
**Checklist Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7))
|
||||||
|
- Sort by: Manual, Alphabetical, Unchecked First, Checked Last
|
||||||
|
- Visual separator between unchecked/checked items with count display
|
||||||
|
- Auto-sort on item toggle and reordering
|
||||||
|
- Drag-only within same group (unchecked/checked)
|
||||||
|
- Smooth fade/slide animations for item transitions
|
||||||
|
- Unit tested with 9 test cases for sorting logic validation
|
||||||
|
|
||||||
|
### 🔄 Sync Improvements
|
||||||
|
|
||||||
|
**Server Deletion Detection** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e))
|
||||||
|
- New `DELETED_ON_SERVER` sync status for multi-device scenarios
|
||||||
|
- Detects when notes are deleted on other clients
|
||||||
|
- Zero performance impact (uses existing PROPFIND data)
|
||||||
|
- Deletion count shown in sync banner: "3 synced · 2 deleted on server"
|
||||||
|
- Edited deleted notes automatically re-upload to server (status → PENDING)
|
||||||
|
|
||||||
|
**Sync Status Legend** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc))
|
||||||
|
- Help button (?) in main screen TopAppBar
|
||||||
|
- Dialog explaining all 5 sync status icons with descriptions
|
||||||
|
- Only visible when sync is configured
|
||||||
|
|
||||||
|
**Live Sync Progress UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a))
|
||||||
|
- Real-time phase indicators: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN
|
||||||
|
- Upload progress shows x/y counter (known total)
|
||||||
|
- Download progress shows count (unknown total)
|
||||||
|
- Single unified SyncProgressBanner (replaces dual system)
|
||||||
|
- Auto-hide: COMPLETED (2s), ERROR (4s)
|
||||||
|
- No misleading counters when nothing to sync
|
||||||
|
- Silent auto-sync stays silent, errors always shown
|
||||||
|
|
||||||
|
**Parallel Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf))
|
||||||
|
- Configurable concurrent downloads (default: 3 simultaneous)
|
||||||
|
- Kotlin coroutines async/awaitAll pattern
|
||||||
|
- Individual download timeout handling
|
||||||
|
- Graceful sequential fallback on concurrent errors
|
||||||
|
- Optimized network utilization for faster sync
|
||||||
|
|
||||||
|
### ✨ UX Improvements
|
||||||
|
|
||||||
|
**Checklist Enhancements:**
|
||||||
|
- Overflow gradient for long text items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93))
|
||||||
|
- Auto-expand on focus, collapse to 5 lines when unfocused
|
||||||
|
- Drag & Drop flicker fix with straddle-target-center detection ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705))
|
||||||
|
- Adjacency filter prevents item jumps during fast drag
|
||||||
|
- Race-condition fix for scroll + move operations
|
||||||
|
|
||||||
|
**Settings UI Polish:**
|
||||||
|
- Smooth language switching without activity recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd))
|
||||||
|
- Grid view as default for new installations ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446))
|
||||||
|
- Sync settings restructured into clear sections: Triggers & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0))
|
||||||
|
- Changelog link added to About screen ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff))
|
||||||
|
|
||||||
|
**Post-Update Changelog Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0))
|
||||||
|
- Shows localized changelog on first launch after update
|
||||||
|
- Material 3 ModalBottomSheet with slide-up animation
|
||||||
|
- Loads F-Droid changelogs via assets (single source of truth)
|
||||||
|
- One-time display per versionCode (stored in SharedPreferences)
|
||||||
|
- Clickable GitHub link for full changelog
|
||||||
|
- Dismissable via button or swipe gesture
|
||||||
|
- Test mode in Debug Settings with reset option
|
||||||
|
|
||||||
|
**Backup Settings Improvements** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed))
|
||||||
|
- New BackupProgressCard with LinearProgressIndicator
|
||||||
|
- 3-phase status system: In Progress → Completion → Clear
|
||||||
|
- Success status shown for 2s, errors for 3s
|
||||||
|
- Removed redundant toast messages
|
||||||
|
- Buttons stay visible and disabled during operations
|
||||||
|
- Exception logging for better error tracking
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
**Widget Text Display** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d))
|
||||||
|
- Fixed text notes showing only 3 lines in widgets
|
||||||
|
- Changed from paragraph-based to line-based rendering
|
||||||
|
- LazyColumn now properly scrolls through all content
|
||||||
|
- Empty lines preserved as 8dp spacers
|
||||||
|
- Preview limits increased: compact 100→120, full 200→300 chars
|
||||||
|
|
||||||
|
### 🔧 Code Quality
|
||||||
|
|
||||||
|
**Detekt Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63))
|
||||||
|
- Resolved all 22 Detekt warnings
|
||||||
|
- Removed 7 unused imports
|
||||||
|
- Defined constants for 5 magic numbers
|
||||||
|
- Optimized state reads with derivedStateOf
|
||||||
|
- Build: 0 Lint errors + 0 Detekt warnings
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- Complete implementation plans for all 23 v1.8.0 features
|
||||||
|
- Widget system architecture and state management docs
|
||||||
|
- Sorting logic unit tests with edge case coverage
|
||||||
|
- F-Droid changelogs (English + German)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-02-04
|
||||||
|
|
||||||
|
### 🐛 Critical Bug Fixes
|
||||||
|
|
||||||
|
#### JSON/Markdown Timestamp Sync
|
||||||
|
|
||||||
|
**Problem:** External editors (Obsidian, Typora, VS Code, custom editors) update Markdown content but don't update YAML `updated:` timestamp, causing the Android app to skip changes.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Server file modification time (`mtime`) is now used as source of truth instead of YAML timestamp
|
||||||
|
- Content changes detected via hash comparison
|
||||||
|
- Notes marked as `PENDING` after Markdown import → JSON automatically re-uploaded on next sync
|
||||||
|
- Fixes sorting issues after external edits
|
||||||
|
|
||||||
|
#### SyncStatus on Server Always PENDING
|
||||||
|
|
||||||
|
**Problem:** All JSON files on server contained `"syncStatus": "PENDING"` even after successful sync, confusing external clients.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Status is now set to `SYNCED` **before** JSON serialization
|
||||||
|
- Server and local copies are now consistent
|
||||||
|
- External web/Tauri editors can correctly interpret sync state
|
||||||
|
|
||||||
|
#### Deletion Tracker Race Condition
|
||||||
|
|
||||||
|
**Problem:** Batch deletes could lose deletion records due to concurrent file access.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Mutex-based synchronization for deletion tracking
|
||||||
|
- New `trackDeletionSafe()` function prevents race conditions
|
||||||
|
- Guarantees zombie note prevention even with rapid deletes
|
||||||
|
|
||||||
|
#### ISO8601 Timezone Parsing
|
||||||
|
|
||||||
|
**Problem:** Markdown imports failed with timezone offsets like `+01:00` or `-05:00`.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Multi-format ISO8601 parser with fallback chain
|
||||||
|
- Supports UTC (Z), timezone offsets (+01:00, +0100), and milliseconds
|
||||||
|
- Compatible with Obsidian, Typora, VS Code timestamps
|
||||||
|
|
||||||
|
### ⚡ Performance Improvements
|
||||||
|
|
||||||
|
#### E-Tag Batch Caching
|
||||||
|
|
||||||
|
- E-Tags are now written in single batch operation instead of N individual writes
|
||||||
|
- Performance gain: ~50-100ms per sync with multiple notes
|
||||||
|
- Reduced disk I/O operations
|
||||||
|
|
||||||
|
#### Memory Leak Prevention
|
||||||
|
|
||||||
|
- `SafeSardineWrapper` now implements `Closeable` for explicit resource cleanup
|
||||||
|
- HTTP connection pool is properly evicted after sync
|
||||||
|
- Prevents socket exhaustion during frequent syncs
|
||||||
|
|
||||||
|
### 🔧 Technical Details
|
||||||
|
|
||||||
|
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` for thread-safe deletion tracking
|
||||||
|
- **IMPL_002:** Pattern-based ISO8601 parser with 8 format variants
|
||||||
|
- **IMPL_003:** Connection pool eviction + dispatcher shutdown in `close()`
|
||||||
|
- **IMPL_004:** Batch `SharedPreferences.Editor` updates
|
||||||
|
- **IMPL_014:** Server `mtime` parameter in `Note.fromMarkdown()`
|
||||||
|
- **IMPL_015:** `syncStatus` set before `toJson()` call
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- External Editor Specification for web/Tauri editor developers
|
||||||
|
- Detailed implementation documentation for all bugfixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.1] - 2026-02-02
|
## [1.7.1] - 2026-02-02
|
||||||
|
|
||||||
### 🐛 Critical Bug Fixes
|
### 🐛 Critical Bug Fixes
|
||||||
@@ -568,8 +784,8 @@ The complete UI has been migrated from XML Views to Jetpack Compose. The app is
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||||
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
- Complete sync architecture documentation
|
||||||
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
- Desktop integration analysis
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
1
android/.gitignore
vendored
1
android/.gitignore
vendored
@@ -18,3 +18,4 @@ local.properties
|
|||||||
key.properties
|
key.properties
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
/app/src/main/assets/changelogs/
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 18 // 🔧 v1.7.1: Android 9 getForegroundInfo Fix (Issue #15)
|
versionCode = 20 // 🎉 v1.8.0: Widgets, Sorting, UI Polish, Post-Update Changelog
|
||||||
versionName = "1.7.1" // 🔧 v1.7.1: Android 9 getForegroundInfo Fix
|
versionName = "1.8.0" // 🎉 v1.8.0: Major Feature Release
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -162,6 +162,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.8.0: Homescreen Widgets
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
implementation("androidx.glance:glance-appwidget:1.1.1")
|
||||||
|
implementation("androidx.glance:glance-material3:1.1.1")
|
||||||
|
|
||||||
// Testing (bleiben so)
|
// Testing (bleiben so)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
@@ -194,3 +200,33 @@ detekt {
|
|||||||
// Parallel-Verarbeitung für schnellere Checks
|
// Parallel-Verarbeitung für schnellere Checks
|
||||||
parallel = true
|
parallel = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📋 v1.8.0: Copy F-Droid changelogs to assets for post-update dialog
|
||||||
|
// Single source of truth: F-Droid changelogs are reused in the app
|
||||||
|
tasks.register<Copy>("copyChangelogsToAssets") {
|
||||||
|
description = "Copies F-Droid changelogs to app assets for post-update dialog"
|
||||||
|
|
||||||
|
from("$rootDir/../fastlane/metadata/android") {
|
||||||
|
include("*/changelogs/*.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
into("$projectDir/src/main/assets/changelogs")
|
||||||
|
|
||||||
|
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
|
||||||
|
eachFile {
|
||||||
|
val parts = relativePath.segments
|
||||||
|
if (parts.size >= 3) {
|
||||||
|
// parts[0] = locale (en-US, de-DE)
|
||||||
|
// parts[1] = "changelogs"
|
||||||
|
// parts[2] = version file (20.txt)
|
||||||
|
relativePath = RelativePath(true, parts[0], parts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run before preBuild to ensure changelogs are available
|
||||||
|
tasks.named("preBuild") {
|
||||||
|
dependsOn("copyChangelogsToAssets")
|
||||||
|
}
|
||||||
14
android/app/proguard-rules.pro
vendored
14
android/app/proguard-rules.pro
vendored
@@ -59,7 +59,19 @@
|
|||||||
-keep class * implements com.google.gson.JsonSerializer
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
-keep class * implements com.google.gson.JsonDeserializer
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
# Keep your app's data classes
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# App-specific rules: Only keep what Gson/reflection needs
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# 🔧 v1.8.1 FIX: Breite Regel verwenden statt spezifischer Klassen
|
||||||
|
#
|
||||||
|
# GRUND: NoteRaw ist eine private data class innerhalb von Note.Companion.
|
||||||
|
# Der JVM-Klassenname ist Note$Companion$NoteRaw, NICHT Note$NoteRaw.
|
||||||
|
# Die spezifische Regel griff nicht → R8 obfuskierte NoteRaw-Felder
|
||||||
|
# → Gson konnte keine JSON-Felder matchen → ALLE Notizen unlesbar!
|
||||||
|
#
|
||||||
|
# Sichere Lösung: Alle App-Klassen behalten (wie in v1.7.2).
|
||||||
|
# APK-Größenoptimierung kann in v1.9.0 sicher evaluiert werden.
|
||||||
-keep class dev.dettmer.simplenotes.** { *; }
|
-keep class dev.dettmer.simplenotes.** { *; }
|
||||||
|
|
||||||
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
|
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
|
||||||
|
|||||||
@@ -69,8 +69,10 @@
|
|||||||
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
||||||
|
|
||||||
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
|
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
|
||||||
|
<!-- v1.8.0: Handle locale changes without recreate for smooth language switching -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.settings.ComposeSettingsActivity"
|
android:name=".ui.settings.ComposeSettingsActivity"
|
||||||
|
android:configChanges="locale|layoutDirection"
|
||||||
android:parentActivityName=".ui.main.ComposeMainActivity"
|
android:parentActivityName=".ui.main.ComposeMainActivity"
|
||||||
android:theme="@style/Theme.SimpleNotes" />
|
android:theme="@style/Theme.SimpleNotes" />
|
||||||
|
|
||||||
@@ -102,6 +104,25 @@
|
|||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
tools:node="merge" />
|
tools:node="merge" />
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: Widget Receiver -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.NoteWidgetReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/note_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: Widget Config Activity -->
|
||||||
|
<activity
|
||||||
|
android:name=".widget.NoteWidgetConfigActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.SimpleNotes" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package dev.dettmer.simplenotes
|
package dev.dettmer.simplenotes
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.ProgressDialog
|
import android.app.ProgressDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -186,10 +187,12 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set URL with protocol prefix in the text field
|
// Set URL with protocol prefix in the text field
|
||||||
|
@Suppress("SetTextI18n") // Technical URL, not UI text
|
||||||
editTextServerUrl.setText("$protocol://$hostPath")
|
editTextServerUrl.setText("$protocol://$hostPath")
|
||||||
} else {
|
} else {
|
||||||
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
|
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
|
||||||
radioHttp.isChecked = true
|
radioHttp.isChecked = true
|
||||||
|
@Suppress("SetTextI18n") // Technical URL, not UI text
|
||||||
editTextServerUrl.setText("http://")
|
editTextServerUrl.setText("http://")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +255,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set new URL with correct protocol
|
// Set new URL with correct protocol
|
||||||
|
@Suppress("SetTextI18n") // Technical URL, not UI text
|
||||||
editTextServerUrl.setText("$newProtocol://$hostPath")
|
editTextServerUrl.setText("$newProtocol://$hostPath")
|
||||||
|
|
||||||
// Move cursor to end
|
// Move cursor to end
|
||||||
@@ -379,7 +383,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
val versionName = BuildConfig.VERSION_NAME
|
val versionName = BuildConfig.VERSION_NAME
|
||||||
val versionCode = BuildConfig.VERSION_CODE
|
val versionCode = BuildConfig.VERSION_CODE
|
||||||
|
|
||||||
textViewAppVersion.text = "Version $versionName ($versionCode)"
|
textViewAppVersion.text = getString(R.string.about_version, versionName, versionCode)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to load version info", e)
|
Logger.e(TAG, "Failed to load version info", e)
|
||||||
textViewAppVersion.text = getString(R.string.version_not_available)
|
textViewAppVersion.text = getString(R.string.version_not_available)
|
||||||
@@ -644,7 +648,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
|
||||||
if (serverUrl.isNullOrEmpty()) {
|
if (serverUrl.isNullOrEmpty()) {
|
||||||
textViewServerStatus.text = "❌ Nicht konfiguriert"
|
textViewServerStatus.text = getString(R.string.server_status_not_configured)
|
||||||
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
|
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -669,10 +673,10 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isReachable) {
|
if (isReachable) {
|
||||||
textViewServerStatus.text = "✅ Erreichbar"
|
textViewServerStatus.text = getString(R.string.server_status_reachable)
|
||||||
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
|
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
|
||||||
} else {
|
} else {
|
||||||
textViewServerStatus.text = "❌ Nicht erreichbar"
|
textViewServerStatus.text = getString(R.string.server_status_unreachable)
|
||||||
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
|
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -818,6 +822,12 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
|
||||||
|
* For Play Store builds, this would need to be changed to
|
||||||
|
* ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly).
|
||||||
|
*/
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
private fun openBatteryOptimizationSettings() {
|
private fun openBatteryOptimizationSettings() {
|
||||||
try {
|
try {
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
@@ -947,6 +957,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Info Text
|
// Info Text
|
||||||
|
@Suppress("SetTextI18n") // Programmatically generated dialog text
|
||||||
val infoText = android.widget.TextView(this).apply {
|
val infoText = android.widget.TextView(this).apply {
|
||||||
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
|
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
|
||||||
textSize = 16f
|
textSize = 16f
|
||||||
@@ -955,7 +966,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Hinweis Text
|
// Hinweis Text
|
||||||
val hintText = android.widget.TextView(this).apply {
|
val hintText = android.widget.TextView(this).apply {
|
||||||
text = "\nℹ️ Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
|
text = getString(R.string.backup_restore_info)
|
||||||
textSize = 14f
|
textSize = 14f
|
||||||
setTypeface(null, android.graphics.Typeface.ITALIC)
|
setTypeface(null, android.graphics.Typeface.ITALIC)
|
||||||
setPadding(0, 20, 0, 0)
|
setPadding(0, 20, 0, 0)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package dev.dettmer.simplenotes
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class NotesAdapter(
|
|||||||
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
|
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
|
||||||
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
|
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
|
||||||
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
|
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> android.R.drawable.ic_menu_delete // 🆕 v1.8.0
|
||||||
}
|
}
|
||||||
imageViewSyncStatus.setImageResource(syncIcon)
|
imageViewSyncStatus.setImageResource(syncIcon)
|
||||||
imageViewSyncStatus.visibility = View.VISIBLE
|
imageViewSyncStatus.visibility = View.VISIBLE
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Sortieroptionen für Checklist-Items im Editor
|
||||||
|
*/
|
||||||
|
enum class ChecklistSortOption {
|
||||||
|
/** Manuelle Reihenfolge (Drag & Drop) — kein Re-Sort */
|
||||||
|
MANUAL,
|
||||||
|
|
||||||
|
/** Alphabetisch A→Z */
|
||||||
|
ALPHABETICAL_ASC,
|
||||||
|
|
||||||
|
/** Alphabetisch Z→A */
|
||||||
|
ALPHABETICAL_DESC,
|
||||||
|
|
||||||
|
/** Unchecked zuerst, dann Checked */
|
||||||
|
UNCHECKED_FIRST,
|
||||||
|
|
||||||
|
/** Checked zuerst, dann Unchecked */
|
||||||
|
CHECKED_FIRST
|
||||||
|
}
|
||||||
@@ -210,11 +210,13 @@ type: ${noteType.name.lowercase()}
|
|||||||
/**
|
/**
|
||||||
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||||
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||||
|
* 🔧 v1.7.2 (IMPL_014): Optional serverModifiedTime für korrekte Timestamp-Sync
|
||||||
*
|
*
|
||||||
* @param md Markdown-String mit YAML Frontmatter
|
* @param md Markdown-String mit YAML Frontmatter
|
||||||
|
* @param serverModifiedTime Optionaler Server-Datei mtime (Priorität über YAML timestamp)
|
||||||
* @return Note-Objekt oder null bei Parse-Fehler
|
* @return Note-Objekt oder null bei Parse-Fehler
|
||||||
*/
|
*/
|
||||||
fun fromMarkdown(md: String): Note? {
|
fun fromMarkdown(md: String, serverModifiedTime: Long? = null): Note? {
|
||||||
return try {
|
return try {
|
||||||
// Parse YAML Frontmatter + Markdown Content
|
// Parse YAML Frontmatter + Markdown Content
|
||||||
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||||
@@ -279,12 +281,22 @@ type: ${noteType.name.lowercase()}
|
|||||||
checklistItems = null
|
checklistItems = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_014): Server mtime hat Priorität über YAML timestamp
|
||||||
|
val yamlUpdatedAt = parseISO8601(metadata["updated"] ?: "")
|
||||||
|
val effectiveUpdatedAt = when {
|
||||||
|
serverModifiedTime != null && serverModifiedTime > yamlUpdatedAt -> {
|
||||||
|
Logger.d(TAG, "Using server mtime ($serverModifiedTime) over YAML ($yamlUpdatedAt)")
|
||||||
|
serverModifiedTime
|
||||||
|
}
|
||||||
|
else -> yamlUpdatedAt
|
||||||
|
}
|
||||||
|
|
||||||
Note(
|
Note(
|
||||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||||
title = title,
|
title = title,
|
||||||
content = content,
|
content = content,
|
||||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||||
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
updatedAt = effectiveUpdatedAt,
|
||||||
deviceId = metadata["device"] ?: "desktop",
|
deviceId = metadata["device"] ?: "desktop",
|
||||||
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
||||||
noteType = noteType,
|
noteType = noteType,
|
||||||
@@ -307,18 +319,71 @@ type: ${noteType.name.lowercase()}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
|
* 🔧 v1.7.2 (IMPL_002): Robustes ISO8601 Parsing mit Multi-Format Unterstützung
|
||||||
|
*
|
||||||
|
* Unterstützte Formate (in Prioritätsreihenfolge):
|
||||||
|
* 1. 2024-12-21T18:00:00Z (UTC mit Z)
|
||||||
|
* 2. 2024-12-21T18:00:00+01:00 (mit Offset)
|
||||||
|
* 3. 2024-12-21T18:00:00+0100 (Offset ohne Doppelpunkt)
|
||||||
|
* 4. 2024-12-21T18:00:00.123Z (mit Millisekunden)
|
||||||
|
* 5. 2024-12-21T18:00:00.123+01:00 (Millisekunden + Offset)
|
||||||
|
* 6. 2024-12-21 18:00:00 (Leerzeichen statt T)
|
||||||
|
*
|
||||||
* Fallback: Aktueller Timestamp bei Fehler
|
* Fallback: Aktueller Timestamp bei Fehler
|
||||||
|
*
|
||||||
|
* @param dateString ISO8601 Datum-String
|
||||||
|
* @return Unix Timestamp in Millisekunden
|
||||||
*/
|
*/
|
||||||
private fun parseISO8601(dateString: String): Long {
|
private fun parseISO8601(dateString: String): Long {
|
||||||
return try {
|
if (dateString.isBlank()) {
|
||||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
return System.currentTimeMillis()
|
||||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
|
||||||
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
|
|
||||||
System.currentTimeMillis() // Fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalisiere: Leerzeichen → T
|
||||||
|
val normalized = dateString.trim().replace(' ', 'T')
|
||||||
|
|
||||||
|
// Format-Patterns in Prioritätsreihenfolge
|
||||||
|
val patterns = listOf(
|
||||||
|
// Mit Timezone Z
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||||
|
|
||||||
|
// Mit Offset XXX (+01:00)
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||||
|
|
||||||
|
// Mit Offset ohne Doppelpunkt (+0100)
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||||
|
|
||||||
|
// Ohne Timezone (interpretiere als UTC)
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Versuche alle Patterns nacheinander
|
||||||
|
for (pattern in patterns) {
|
||||||
|
@Suppress("SwallowedException") // Intentional: try all patterns before logging
|
||||||
|
try {
|
||||||
|
val sdf = SimpleDateFormat(pattern, Locale.US)
|
||||||
|
// Für Patterns ohne Timezone: UTC annehmen
|
||||||
|
if (!pattern.contains("XXX") && !pattern.contains("Z")) {
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val parsed = sdf.parse(normalized)
|
||||||
|
if (parsed != null) {
|
||||||
|
return parsed.time
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 🔇 Exception intentionally swallowed - try next pattern
|
||||||
|
// Only log if no pattern matches (see fallback below)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback wenn kein Pattern passt
|
||||||
|
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString' with any pattern, using current time")
|
||||||
|
return System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Sortierrichtung
|
||||||
|
*/
|
||||||
|
enum class SortDirection(val prefsValue: String) {
|
||||||
|
ASCENDING("asc"),
|
||||||
|
DESCENDING("desc");
|
||||||
|
|
||||||
|
fun toggle(): SortDirection = when (this) {
|
||||||
|
ASCENDING -> DESCENDING
|
||||||
|
DESCENDING -> ASCENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPrefsValue(value: String): SortDirection {
|
||||||
|
return entries.find { it.prefsValue == value } ?: DESCENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Sortieroptionen für die Notizliste
|
||||||
|
*/
|
||||||
|
enum class SortOption(val prefsValue: String) {
|
||||||
|
/** Zuletzt bearbeitete zuerst (Default) */
|
||||||
|
UPDATED_AT("updatedAt"),
|
||||||
|
|
||||||
|
/** Zuletzt erstellte zuerst */
|
||||||
|
CREATED_AT("createdAt"),
|
||||||
|
|
||||||
|
/** Alphabetisch nach Titel */
|
||||||
|
TITLE("title"),
|
||||||
|
|
||||||
|
/** Nach Notiz-Typ (Text / Checkliste) */
|
||||||
|
NOTE_TYPE("noteType");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPrefsValue(value: String): SortOption {
|
||||||
|
return entries.find { it.prefsValue == value } ?: UPDATED_AT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
package dev.dettmer.simplenotes.models
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync-Status einer Notiz
|
||||||
|
*
|
||||||
|
* v1.4.0: Initial (LOCAL_ONLY, SYNCED, PENDING, CONFLICT)
|
||||||
|
* v1.8.0: DELETED_ON_SERVER hinzugefügt
|
||||||
|
*/
|
||||||
enum class SyncStatus {
|
enum class SyncStatus {
|
||||||
LOCAL_ONLY, // Noch nie gesynct
|
LOCAL_ONLY, // Noch nie gesynct
|
||||||
SYNCED, // Erfolgreich gesynct
|
SYNCED, // Erfolgreich gesynct
|
||||||
PENDING, // Wartet auf Sync
|
PENDING, // Wartet auf Sync
|
||||||
CONFLICT // Konflikt erkannt
|
CONFLICT, // Konflikt erkannt
|
||||||
|
DELETED_ON_SERVER // 🆕 v1.8.0: Server hat gelöscht, lokal noch vorhanden
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import dev.dettmer.simplenotes.models.DeletionTracker
|
|||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class NotesStorage(private val context: Context) {
|
class NotesStorage(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NotesStorage"
|
private const val TAG = "NotesStorage"
|
||||||
|
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
|
||||||
|
private val deletionTrackerMutex = Mutex()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||||
@@ -31,11 +35,16 @@ class NotesStorage(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt alle Notizen aus dem lokalen Speicher.
|
||||||
|
*
|
||||||
|
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
|
||||||
|
* damit der User die Sortierung konfigurieren kann.
|
||||||
|
*/
|
||||||
fun loadAllNotes(): List<Note> {
|
fun loadAllNotes(): List<Note> {
|
||||||
return notesDir.listFiles()
|
return notesDir.listFiles()
|
||||||
?.filter { it.extension == "json" }
|
?.filter { it.extension == "json" }
|
||||||
?.mapNotNull { Note.fromJson(it.readText()) }
|
?.mapNotNull { Note.fromJson(it.readText()) }
|
||||||
?.sortedByDescending { it.updatedAt }
|
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +116,30 @@ class NotesStorage(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
|
||||||
|
*
|
||||||
|
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
|
||||||
|
* auf den Deletion Tracker.
|
||||||
|
*
|
||||||
|
* @param noteId ID der gelöschten Notiz
|
||||||
|
* @param deviceId Geräte-ID für Konflikt-Erkennung
|
||||||
|
*/
|
||||||
|
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
|
||||||
|
deletionTrackerMutex.withLock {
|
||||||
|
val tracker = loadDeletionTracker()
|
||||||
|
tracker.addDeletion(noteId, deviceId)
|
||||||
|
saveDeletionTracker(tracker)
|
||||||
|
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy-Methode ohne Mutex-Schutz.
|
||||||
|
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
||||||
|
*
|
||||||
|
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
||||||
|
*/
|
||||||
fun trackDeletion(noteId: String, deviceId: String) {
|
fun trackDeletion(noteId: String, deviceId: String) {
|
||||||
val tracker = loadDeletionTracker()
|
val tracker = loadDeletionTracker()
|
||||||
tracker.addDeletion(noteId, deviceId)
|
tracker.addDeletion(noteId, deviceId)
|
||||||
|
|||||||
@@ -5,21 +5,29 @@ import com.thegrizzlylabs.sardineandroid.Sardine
|
|||||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.Closeable
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
private const val HTTP_METHOD_NOT_ALLOWED = 405
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert
|
* 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert
|
||||||
|
* 🔧 v1.7.2 (IMPL_003): Implementiert Closeable für explizites Resource-Management
|
||||||
*
|
*
|
||||||
* Hintergrund:
|
* Hintergrund:
|
||||||
* - OkHttpSardine.exists() schließt den Response-Body nicht
|
* - OkHttpSardine.exists() schließt den Response-Body nicht
|
||||||
* - Dies führt zu "connection leaked" Warnungen im Log
|
* - Dies führt zu "connection leaked" Warnungen im Log
|
||||||
* - Kann bei vielen Requests zu Socket-Exhaustion führen
|
* - Kann bei vielen Requests zu Socket-Exhaustion führen
|
||||||
|
* - Session-Cache hält Referenzen ohne explizites Cleanup
|
||||||
*
|
*
|
||||||
* Lösung:
|
* Lösung:
|
||||||
* - Eigene exists()-Implementation mit korrektem Response-Cleanup
|
* - Eigene exists()-Implementation mit korrektem Response-Cleanup
|
||||||
* - Preemptive Authentication um 401-Round-Trips zu vermeiden
|
* - Preemptive Authentication um 401-Round-Trips zu vermeiden
|
||||||
|
* - Closeable Pattern für explizite Resource-Freigabe
|
||||||
*
|
*
|
||||||
* @see <a href="https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/">OkHttp Response Body Docs</a>
|
* @see <a href="https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/">OkHttp Response Body Docs</a>
|
||||||
*/
|
*/
|
||||||
@@ -27,7 +35,7 @@ class SafeSardineWrapper private constructor(
|
|||||||
private val delegate: OkHttpSardine,
|
private val delegate: OkHttpSardine,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val authHeader: String
|
private val authHeader: String
|
||||||
) : Sardine by delegate {
|
) : Sardine by delegate, Closeable {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SafeSardine"
|
private const val TAG = "SafeSardine"
|
||||||
@@ -48,6 +56,10 @@ class SafeSardineWrapper private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.7.2 (IMPL_003): Track ob bereits geschlossen
|
||||||
|
@Volatile
|
||||||
|
private var isClosed = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ✅ Sichere exists()-Implementation mit Response Cleanup
|
* ✅ Sichere exists()-Implementation mit Response Cleanup
|
||||||
*
|
*
|
||||||
@@ -101,5 +113,98 @@ class SafeSardineWrapper private constructor(
|
|||||||
return delegate.list(url, depth)
|
return delegate.list(url, depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Sichere put()-Implementation mit Response Cleanup
|
||||||
|
*
|
||||||
|
* Im Gegensatz zu OkHttpSardine.put() wird hier der Response-Body garantiert geschlossen.
|
||||||
|
* Verhindert "connection leaked" Warnungen.
|
||||||
|
*/
|
||||||
|
override fun put(url: String, data: ByteArray, contentType: String?) {
|
||||||
|
val mediaType = contentType?.toMediaTypeOrNull()
|
||||||
|
val body = data.toRequestBody(mediaType)
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.put(body)
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
okHttpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw java.io.IOException("PUT failed: ${response.code} ${response.message}")
|
||||||
|
}
|
||||||
|
Logger.d(TAG, "put($url) → ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Sichere delete()-Implementation mit Response Cleanup
|
||||||
|
*
|
||||||
|
* Im Gegensatz zu OkHttpSardine.delete() wird hier der Response-Body garantiert geschlossen.
|
||||||
|
* Verhindert "connection leaked" Warnungen.
|
||||||
|
*/
|
||||||
|
override fun delete(url: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.delete()
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
okHttpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw java.io.IOException("DELETE failed: ${response.code} ${response.message}")
|
||||||
|
}
|
||||||
|
Logger.d(TAG, "delete($url) → ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Sichere createDirectory()-Implementation mit Response Cleanup
|
||||||
|
*
|
||||||
|
* Im Gegensatz zu OkHttpSardine.createDirectory() wird hier der Response-Body garantiert geschlossen.
|
||||||
|
* Verhindert "connection leaked" Warnungen.
|
||||||
|
* 405 (Method Not Allowed) wird toleriert da dies bedeutet, dass der Ordner bereits existiert.
|
||||||
|
*/
|
||||||
|
override fun createDirectory(url: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.method("MKCOL", null)
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
okHttpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful && response.code != HTTP_METHOD_NOT_ALLOWED) { // 405 = already exists
|
||||||
|
throw java.io.IOException("MKCOL failed: ${response.code} ${response.message}")
|
||||||
|
}
|
||||||
|
Logger.d(TAG, "createDirectory($url) → ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.2 (IMPL_003): Schließt alle offenen Verbindungen
|
||||||
|
*
|
||||||
|
* Wichtig: Nach close() darf der Client nicht mehr verwendet werden!
|
||||||
|
* Eviction von Connection Pool Einträgen und Cleanup von internen Ressourcen.
|
||||||
|
*/
|
||||||
|
override fun close() {
|
||||||
|
if (isClosed) {
|
||||||
|
Logger.d(TAG, "Already closed, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// OkHttpClient Connection Pool räumen
|
||||||
|
okHttpClient.connectionPool.evictAll()
|
||||||
|
|
||||||
|
// Dispatcher shutdown (beendet laufende Calls)
|
||||||
|
okHttpClient.dispatcher.cancelAll()
|
||||||
|
|
||||||
|
isClosed = true
|
||||||
|
Logger.d(TAG, "✅ Closed successfully (connections evicted)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to close", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
|
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Detaillierter Sync-Fortschritt für UI
|
||||||
|
*
|
||||||
|
* Einziges Banner-System für den gesamten Sync-Lebenszyklus:
|
||||||
|
* - PREPARING: Sofort beim Klick, bleibt während Vor-Checks und Server-Prüfung
|
||||||
|
* - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen
|
||||||
|
* - COMPLETED / ERROR: Ergebnis mit Nachricht + Auto-Hide
|
||||||
|
*
|
||||||
|
* Ersetzt das alte duale Banner-System (SyncStatusBanner + SyncProgressBanner)
|
||||||
|
*/
|
||||||
|
data class SyncProgress(
|
||||||
|
val phase: SyncPhase = SyncPhase.IDLE,
|
||||||
|
val current: Int = 0,
|
||||||
|
val total: Int = 0,
|
||||||
|
val currentFileName: String? = null,
|
||||||
|
val resultMessage: String? = null,
|
||||||
|
val silent: Boolean = false,
|
||||||
|
val startTime: Long = System.currentTimeMillis()
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Fortschritt als Float zwischen 0.0 und 1.0
|
||||||
|
*/
|
||||||
|
val progress: Float
|
||||||
|
get() = if (total > 0) current.toFloat() / total else 0f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fortschritt als Prozent (0-100)
|
||||||
|
*/
|
||||||
|
val percentComplete: Int
|
||||||
|
get() = (progress * 100).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergangene Zeit seit Start in Millisekunden
|
||||||
|
*/
|
||||||
|
val elapsedMs: Long
|
||||||
|
get() = System.currentTimeMillis() - startTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geschätzte verbleibende Zeit in Millisekunden
|
||||||
|
* Basiert auf durchschnittlicher Zeit pro Item
|
||||||
|
*/
|
||||||
|
val estimatedRemainingMs: Long?
|
||||||
|
get() {
|
||||||
|
if (current == 0 || total == 0) return null
|
||||||
|
val avgTimePerItem = elapsedMs / current
|
||||||
|
val remaining = total - current
|
||||||
|
return avgTimePerItem * remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob das Banner sichtbar sein soll
|
||||||
|
* Silent syncs zeigen nie ein Banner
|
||||||
|
*/
|
||||||
|
val isVisible: Boolean
|
||||||
|
get() = !silent && phase != SyncPhase.IDLE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob gerade ein aktiver Sync läuft (mit Spinner)
|
||||||
|
*/
|
||||||
|
val isActiveSync: Boolean
|
||||||
|
get() = phase in listOf(
|
||||||
|
SyncPhase.PREPARING,
|
||||||
|
SyncPhase.UPLOADING,
|
||||||
|
SyncPhase.DOWNLOADING,
|
||||||
|
SyncPhase.IMPORTING_MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val IDLE = SyncProgress(phase = SyncPhase.IDLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Sync-Phasen für detailliertes Progress-Tracking
|
||||||
|
*/
|
||||||
|
enum class SyncPhase {
|
||||||
|
/** Kein Sync aktiv */
|
||||||
|
IDLE,
|
||||||
|
|
||||||
|
/** Sync wurde gestartet, Vor-Checks laufen (hasUnsyncedChanges, isReachable, Server-Verzeichnis) */
|
||||||
|
PREPARING,
|
||||||
|
|
||||||
|
/** Lädt lokale Änderungen auf den Server hoch */
|
||||||
|
UPLOADING,
|
||||||
|
|
||||||
|
/** Lädt Server-Änderungen herunter */
|
||||||
|
DOWNLOADING,
|
||||||
|
|
||||||
|
/** Importiert Markdown-Dateien vom Server */
|
||||||
|
IMPORTING_MARKDOWN,
|
||||||
|
|
||||||
|
/** Sync erfolgreich abgeschlossen */
|
||||||
|
COMPLETED,
|
||||||
|
|
||||||
|
/** Sync mit Fehler abgebrochen */
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
package dev.dettmer.simplenotes.sync
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis eines Sync-Vorgangs
|
||||||
|
*
|
||||||
|
* v1.7.0: Initial
|
||||||
|
* v1.8.0: deletedOnServerCount hinzugefügt
|
||||||
|
*/
|
||||||
data class SyncResult(
|
data class SyncResult(
|
||||||
val isSuccess: Boolean,
|
val isSuccess: Boolean,
|
||||||
val syncedCount: Int = 0,
|
val syncedCount: Int = 0,
|
||||||
val conflictCount: Int = 0,
|
val conflictCount: Int = 0,
|
||||||
|
val deletedOnServerCount: Int = 0, // 🆕 v1.8.0
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
) {
|
) {
|
||||||
val hasConflicts: Boolean
|
val hasConflicts: Boolean get() = conflictCount > 0
|
||||||
get() = conflictCount > 0
|
val hasServerDeletions: Boolean get() = deletedOnServerCount > 0 // 🆕 v1.8.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,46 +3,53 @@ package dev.dettmer.simplenotes.sync
|
|||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
|
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
|
||||||
|
* 🆕 v1.8.0: Komplett überarbeitet - SyncProgress ist jetzt das einzige Banner-System
|
||||||
*
|
*
|
||||||
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
|
* SyncProgress (StateFlow) steuert den gesamten Sync-Lebenszyklus:
|
||||||
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
|
* PREPARING → [UPLOADING] → [DOWNLOADING] → [IMPORTING_MARKDOWN] → COMPLETED/ERROR → IDLE
|
||||||
|
*
|
||||||
|
* SyncStatus (LiveData) wird nur noch intern für Mutex/Silent-Tracking verwendet.
|
||||||
*/
|
*/
|
||||||
object SyncStateManager {
|
object SyncStateManager {
|
||||||
|
|
||||||
private const val TAG = "SyncStateManager"
|
private const val TAG = "SyncStateManager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mögliche Sync-Zustände
|
* Mögliche Sync-Zustände (intern für Mutex + PullToRefresh)
|
||||||
*/
|
*/
|
||||||
enum class SyncState {
|
enum class SyncState {
|
||||||
IDLE, // Kein Sync aktiv
|
IDLE,
|
||||||
SYNCING, // Sync läuft gerade (Banner sichtbar)
|
SYNCING,
|
||||||
SYNCING_SILENT, // v1.5.0: Sync läuft im Hintergrund (kein Banner, z.B. onResume)
|
SYNCING_SILENT,
|
||||||
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
|
COMPLETED,
|
||||||
ERROR // Sync fehlgeschlagen (kurz anzeigen)
|
ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detaillierte Sync-Informationen für UI
|
* Interne Sync-Informationen (für Mutex-Management + Silent-Tracking)
|
||||||
*/
|
*/
|
||||||
data class SyncStatus(
|
data class SyncStatus(
|
||||||
val state: SyncState = SyncState.IDLE,
|
val state: SyncState = SyncState.IDLE,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
|
val source: String? = null,
|
||||||
val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt
|
val silent: Boolean = false,
|
||||||
val timestamp: Long = System.currentTimeMillis()
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Private mutable LiveData
|
// Intern: Mutex + PullToRefresh State
|
||||||
private val _syncStatus = MutableLiveData(SyncStatus())
|
private val _syncStatus = MutableLiveData(SyncStatus())
|
||||||
|
|
||||||
// Public immutable LiveData für Observer
|
|
||||||
val syncStatus: LiveData<SyncStatus> = _syncStatus
|
val syncStatus: LiveData<SyncStatus> = _syncStatus
|
||||||
|
|
||||||
// Lock für Thread-Sicherheit
|
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
||||||
|
private val _syncProgress = MutableStateFlow(SyncProgress.IDLE)
|
||||||
|
val syncProgress: StateFlow<SyncProgress> = _syncProgress.asStateFlow()
|
||||||
|
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,54 +63,63 @@ object SyncStateManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Versucht einen Sync zu starten.
|
* Versucht einen Sync zu starten.
|
||||||
* @param source Quelle des Syncs (für Logging)
|
* Bei silent=false: Setzt sofort PREPARING-Phase → Banner erscheint instant
|
||||||
* @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync)
|
* Bei silent=true: Setzt silent-Flag → kein Banner wird angezeigt
|
||||||
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
|
|
||||||
*/
|
*/
|
||||||
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
|
fun tryStartSync(source: String, silent: Boolean = false): Boolean {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
|
Logger.d(TAG, "⚠️ Sync already in progress, rejecting from: $source")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
|
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
|
||||||
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
|
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
|
||||||
|
|
||||||
_syncStatus.postValue(
|
_syncStatus.postValue(
|
||||||
SyncStatus(
|
SyncStatus(
|
||||||
state = syncState,
|
state = syncState,
|
||||||
message = "Synchronisiere...",
|
|
||||||
source = source,
|
source = source,
|
||||||
silent = silent // v1.5.0: Merkt sich ob silent für markCompleted()
|
silent = silent
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Sofort PREPARING-Phase setzen (Banner erscheint instant)
|
||||||
|
_syncProgress.value = SyncProgress(
|
||||||
|
phase = SyncPhase.PREPARING,
|
||||||
|
silent = silent,
|
||||||
|
startTime = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markiert Sync als erfolgreich abgeschlossen
|
* Markiert Sync als erfolgreich abgeschlossen
|
||||||
* v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner)
|
* Bei Silent-Sync: direkt auf IDLE (kein Banner)
|
||||||
|
* Bei normalem Sync: COMPLETED mit Nachricht (auto-hide durch UI)
|
||||||
*/
|
*/
|
||||||
fun markCompleted(message: String? = null) {
|
fun markCompleted(message: String? = null) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val current = _syncStatus.value
|
val current = _syncStatus.value
|
||||||
val currentSource = current?.source
|
|
||||||
val wasSilent = current?.silent == true
|
val wasSilent = current?.silent == true
|
||||||
|
val currentSource = current?.source
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
|
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
|
||||||
|
|
||||||
if (wasSilent) {
|
if (wasSilent) {
|
||||||
// v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen
|
// Silent-Sync: Direkt auf IDLE - kein Banner
|
||||||
_syncStatus.postValue(SyncStatus())
|
_syncStatus.postValue(SyncStatus())
|
||||||
|
_syncProgress.value = SyncProgress.IDLE
|
||||||
} else {
|
} else {
|
||||||
// Normaler Sync - COMPLETED State anzeigen
|
// Normaler Sync: COMPLETED mit Nachricht anzeigen
|
||||||
_syncStatus.postValue(
|
_syncStatus.postValue(
|
||||||
SyncStatus(
|
SyncStatus(state = SyncState.COMPLETED, message = message, source = currentSource)
|
||||||
state = SyncState.COMPLETED,
|
)
|
||||||
message = message,
|
_syncProgress.value = SyncProgress(
|
||||||
source = currentSource
|
phase = SyncPhase.COMPLETED,
|
||||||
)
|
resultMessage = message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,38 +127,90 @@ object SyncStateManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Markiert Sync als fehlgeschlagen
|
* Markiert Sync als fehlgeschlagen
|
||||||
|
* Bei Silent-Sync: Fehler trotzdem anzeigen (wichtig für User)
|
||||||
*/
|
*/
|
||||||
fun markError(errorMessage: String?) {
|
fun markError(errorMessage: String?) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val currentSource = _syncStatus.value?.source
|
val current = _syncStatus.value
|
||||||
|
val wasSilent = current?.silent == true
|
||||||
|
val currentSource = current?.source
|
||||||
|
|
||||||
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
|
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
|
||||||
|
|
||||||
_syncStatus.postValue(
|
_syncStatus.postValue(
|
||||||
SyncStatus(
|
SyncStatus(state = SyncState.ERROR, message = errorMessage, source = currentSource)
|
||||||
state = SyncState.ERROR,
|
)
|
||||||
message = errorMessage,
|
|
||||||
source = currentSource
|
// Fehler immer anzeigen (auch bei Silent-Sync)
|
||||||
)
|
_syncProgress.value = SyncProgress(
|
||||||
|
phase = SyncPhase.ERROR,
|
||||||
|
resultMessage = errorMessage,
|
||||||
|
silent = false // Fehler nie silent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt Status zurück auf IDLE
|
* Setzt alles zurück auf IDLE
|
||||||
*/
|
*/
|
||||||
fun reset() {
|
fun reset() {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
_syncStatus.postValue(SyncStatus())
|
_syncStatus.postValue(SyncStatus())
|
||||||
|
_syncProgress.value = SyncProgress.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.8.0: Detailliertes Progress-Tracking (während syncNotes())
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den detaillierten Sync-Fortschritt
|
||||||
|
* Behält silent-Flag und startTime der aktuellen Session bei
|
||||||
|
*/
|
||||||
|
fun updateProgress(
|
||||||
|
phase: SyncPhase,
|
||||||
|
current: Int = 0,
|
||||||
|
total: Int = 0,
|
||||||
|
currentFileName: String? = null
|
||||||
|
) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val existing = _syncProgress.value
|
||||||
|
_syncProgress.value = SyncProgress(
|
||||||
|
phase = phase,
|
||||||
|
current = current,
|
||||||
|
total = total,
|
||||||
|
currentFileName = currentFileName,
|
||||||
|
silent = existing.silent,
|
||||||
|
startTime = existing.startTime
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
|
* Inkrementiert den Fortschritt um 1
|
||||||
|
* Praktisch für Schleifen: nach jedem tatsächlichen Download
|
||||||
*/
|
*/
|
||||||
fun updateMessage(message: String) {
|
fun incrementProgress(currentFileName: String? = null) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val current = _syncStatus.value ?: return
|
val current = _syncProgress.value
|
||||||
if (current.state == SyncState.SYNCING) {
|
_syncProgress.value = current.copy(
|
||||||
_syncStatus.postValue(current.copy(message = message))
|
current = current.current + 1,
|
||||||
|
currentFileName = currentFileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Progress zurück auf IDLE (am Ende von syncNotes())
|
||||||
|
* Wird NICHT für COMPLETED/ERROR verwendet - nur für Cleanup
|
||||||
|
*/
|
||||||
|
fun resetProgress() {
|
||||||
|
// Nicht zurücksetzen wenn COMPLETED/ERROR - die UI braucht den State noch für auto-hide
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = _syncProgress.value
|
||||||
|
if (current.phase != SyncPhase.COMPLETED && current.phase != SyncPhase.ERROR) {
|
||||||
|
_syncProgress.value = SyncProgress.IDLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,20 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
broadcastSyncCompleted(true, result.syncedCount)
|
broadcastSyncCompleted(true, result.syncedCount)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Alle Widgets aktualisieren nach Sync
|
||||||
|
try {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, " Updating widgets...")
|
||||||
|
}
|
||||||
|
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(applicationContext)
|
||||||
|
val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java)
|
||||||
|
glanceIds.forEach { id ->
|
||||||
|
dev.dettmer.simplenotes.widget.NoteWidget().update(applicationContext, id)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to update widgets: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
|||||||
@@ -10,21 +10,22 @@ import dev.dettmer.simplenotes.models.DeletionTracker
|
|||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.sync.parallel.DownloadTask
|
||||||
|
import dev.dettmer.simplenotes.sync.parallel.DownloadTaskResult
|
||||||
|
import dev.dettmer.simplenotes.sync.parallel.ParallelDownloader
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.SyncException
|
import dev.dettmer.simplenotes.utils.SyncException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.net.SocketFactory
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of manual Markdown sync operation
|
* Result of manual Markdown sync operation
|
||||||
@@ -95,6 +96,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* @return true if VPN interface is detected
|
* @return true if VPN interface is detected
|
||||||
*/
|
*/
|
||||||
|
@Suppress("unused") // Reserved for future VPN detection feature
|
||||||
private fun isVpnInterfaceActive(): Boolean {
|
private fun isVpnInterfaceActive(): Boolean {
|
||||||
try {
|
try {
|
||||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
|
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
|
||||||
@@ -164,8 +166,19 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚡ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
|
* ⚡ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
|
||||||
|
* 🔧 v1.7.2 (IMPL_003): Schließt Sardine-Client explizit für Resource-Cleanup
|
||||||
*/
|
*/
|
||||||
private fun clearSessionCache() {
|
private fun clearSessionCache() {
|
||||||
|
// 🆕 v1.7.2: Explizites Schließen des Sardine-Clients
|
||||||
|
sessionSardine?.let { sardine ->
|
||||||
|
try {
|
||||||
|
sardine.close()
|
||||||
|
Logger.d(TAG, "🧹 Sardine client closed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to close Sardine client: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sessionSardine = null
|
sessionSardine = null
|
||||||
notesDirEnsured = false
|
notesDirEnsured = false
|
||||||
markdownDirEnsured = false
|
markdownDirEnsured = false
|
||||||
@@ -588,6 +601,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
|
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
|
||||||
|
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
|
// 🆕 v1.8.0: Banner bleibt in PREPARING bis echte Arbeit (Upload/Download) anfällt
|
||||||
|
|
||||||
Logger.d(TAG, "📍 Step 1: Getting Sardine client")
|
Logger.d(TAG, "📍 Step 1: Getting Sardine client")
|
||||||
|
|
||||||
val sardine = try {
|
val sardine = try {
|
||||||
@@ -631,11 +646,23 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Ensure notes-md/ directory exists (for Markdown export)
|
// Ensure notes-md/ directory exists (for Markdown export)
|
||||||
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Phase 2 - Uploading (Phase wird nur bei echten Uploads gesetzt)
|
||||||
Logger.d(TAG, "📍 Step 4: Uploading local notes")
|
Logger.d(TAG, "📍 Step 4: Uploading local notes")
|
||||||
// Upload local notes
|
// Upload local notes
|
||||||
try {
|
try {
|
||||||
Logger.d(TAG, "⬆️ Uploading local notes...")
|
Logger.d(TAG, "⬆️ Uploading local notes...")
|
||||||
val uploadedCount = uploadLocalNotes(sardine, serverUrl)
|
val uploadedCount = uploadLocalNotes(
|
||||||
|
sardine,
|
||||||
|
serverUrl,
|
||||||
|
onProgress = { current, total, noteTitle ->
|
||||||
|
SyncStateManager.updateProgress(
|
||||||
|
phase = SyncPhase.UPLOADING,
|
||||||
|
current = current,
|
||||||
|
total = total,
|
||||||
|
currentFileName = noteTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
syncedCount += uploadedCount
|
syncedCount += uploadedCount
|
||||||
Logger.d(TAG, "✅ Uploaded: $uploadedCount notes")
|
Logger.d(TAG, "✅ Uploaded: $uploadedCount notes")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -644,21 +671,35 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Phase 3 - Downloading (Phase wird nur bei echten Downloads gesetzt)
|
||||||
Logger.d(TAG, "📍 Step 5: Downloading remote notes")
|
Logger.d(TAG, "📍 Step 5: Downloading remote notes")
|
||||||
// Download remote notes
|
// Download remote notes
|
||||||
|
var deletedOnServerCount = 0 // 🆕 v1.8.0
|
||||||
try {
|
try {
|
||||||
Logger.d(TAG, "⬇️ Downloading remote notes...")
|
Logger.d(TAG, "⬇️ Downloading remote notes...")
|
||||||
val downloadResult = downloadRemoteNotes(
|
val downloadResult = downloadRemoteNotes(
|
||||||
sardine,
|
sardine,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
includeRootFallback = true // ✅ v1.3.0: Enable for v1.2.0 compatibility
|
includeRootFallback = true, // ✅ v1.3.0: Enable for v1.2.0 compatibility
|
||||||
|
onProgress = { current, _, noteTitle ->
|
||||||
|
// 🆕 v1.8.0: Phase wird erst beim ersten echten Download gesetzt
|
||||||
|
// current = laufender Zähler (downloadedCount), kein Total → kein irreführender x/y Counter
|
||||||
|
SyncStateManager.updateProgress(
|
||||||
|
phase = SyncPhase.DOWNLOADING,
|
||||||
|
current = current,
|
||||||
|
total = 0,
|
||||||
|
currentFileName = noteTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
syncedCount += downloadResult.downloadedCount
|
syncedCount += downloadResult.downloadedCount
|
||||||
conflictCount += downloadResult.conflictCount
|
conflictCount += downloadResult.conflictCount
|
||||||
|
deletedOnServerCount = downloadResult.deletedOnServerCount // 🆕 v1.8.0
|
||||||
Logger.d(
|
Logger.d(
|
||||||
TAG,
|
TAG,
|
||||||
"✅ Downloaded: ${downloadResult.downloadedCount} notes, " +
|
"✅ Downloaded: ${downloadResult.downloadedCount} notes, " +
|
||||||
"Conflicts: ${downloadResult.conflictCount}"
|
"Conflicts: ${downloadResult.conflictCount}, " +
|
||||||
|
"Deleted on server: ${downloadResult.deletedOnServerCount}" // 🆕 v1.8.0
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
|
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
|
||||||
@@ -667,14 +708,26 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "📍 Step 6: Auto-import Markdown (if enabled)")
|
Logger.d(TAG, "📍 Step 6: Auto-import Markdown (if enabled)")
|
||||||
|
|
||||||
// Auto-import Markdown files from server
|
// Auto-import Markdown files from server
|
||||||
var markdownImportedCount = 0
|
var markdownImportedCount = 0
|
||||||
try {
|
try {
|
||||||
val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
|
val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
|
||||||
if (markdownAutoImportEnabled) {
|
if (markdownAutoImportEnabled) {
|
||||||
|
// 🆕 v1.8.0: Phase nur setzen wenn Feature aktiv
|
||||||
|
SyncStateManager.updateProgress(phase = SyncPhase.IMPORTING_MARKDOWN)
|
||||||
|
|
||||||
Logger.d(TAG, "📥 Auto-importing Markdown files...")
|
Logger.d(TAG, "📥 Auto-importing Markdown files...")
|
||||||
markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
|
markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
|
||||||
Logger.d(TAG, "✅ Auto-imported: $markdownImportedCount Markdown files")
|
Logger.d(TAG, "✅ Auto-imported: $markdownImportedCount Markdown files")
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_014): Re-upload notes that were updated from Markdown
|
||||||
|
if (markdownImportedCount > 0) {
|
||||||
|
Logger.d(TAG, "📤 Re-uploading notes updated from Markdown (JSON sync)...")
|
||||||
|
val reUploadedCount = uploadLocalNotes(sardine, serverUrl)
|
||||||
|
Logger.d(TAG, "✅ Re-uploaded: $reUploadedCount notes (JSON updated on server)")
|
||||||
|
syncedCount += reUploadedCount
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, "⏭️ Markdown auto-import disabled")
|
Logger.d(TAG, "⏭️ Markdown auto-import disabled")
|
||||||
}
|
}
|
||||||
@@ -684,6 +737,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "📍 Step 7: Saving sync timestamp")
|
Logger.d(TAG, "📍 Step 7: Saving sync timestamp")
|
||||||
|
|
||||||
// Update last sync timestamp
|
// Update last sync timestamp
|
||||||
try {
|
try {
|
||||||
saveLastSyncTimestamp()
|
saveLastSyncTimestamp()
|
||||||
@@ -707,12 +761,23 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
if (markdownImportedCount > 0 && syncedCount > 0) {
|
if (markdownImportedCount > 0 && syncedCount > 0) {
|
||||||
Logger.d(TAG, "📝 Including $markdownImportedCount Markdown file updates")
|
Logger.d(TAG, "📝 Including $markdownImportedCount Markdown file updates")
|
||||||
}
|
}
|
||||||
|
if (deletedOnServerCount > 0) { // 🆕 v1.8.0
|
||||||
|
Logger.d(TAG, "🗑️ Detected $deletedOnServerCount notes deleted on server")
|
||||||
|
}
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Phase 6 - Completed
|
||||||
|
SyncStateManager.updateProgress(
|
||||||
|
phase = SyncPhase.COMPLETED,
|
||||||
|
current = effectiveSyncedCount,
|
||||||
|
total = effectiveSyncedCount
|
||||||
|
)
|
||||||
|
|
||||||
SyncResult(
|
SyncResult(
|
||||||
isSuccess = true,
|
isSuccess = true,
|
||||||
syncedCount = effectiveSyncedCount,
|
syncedCount = effectiveSyncedCount,
|
||||||
conflictCount = conflictCount
|
conflictCount = conflictCount,
|
||||||
|
deletedOnServerCount = deletedOnServerCount // 🆕 v1.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -724,6 +789,9 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
Logger.e(TAG, "═══════════════════════════════════════")
|
Logger.e(TAG, "═══════════════════════════════════════")
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Phase ERROR
|
||||||
|
SyncStateManager.updateProgress(phase = SyncPhase.ERROR)
|
||||||
|
|
||||||
SyncResult(
|
SyncResult(
|
||||||
isSuccess = false,
|
isSuccess = false,
|
||||||
errorMessage = when (e) {
|
errorMessage = when (e) {
|
||||||
@@ -746,6 +814,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
} finally {
|
} finally {
|
||||||
// ⚡ v1.3.1: Session-Caches leeren
|
// ⚡ v1.3.1: Session-Caches leeren
|
||||||
clearSessionCache()
|
clearSessionCache()
|
||||||
|
// 🆕 v1.8.0: Reset progress state
|
||||||
|
SyncStateManager.resetProgress()
|
||||||
// 🔒 v1.3.1: Sync-Mutex freigeben
|
// 🔒 v1.3.1: Sync-Mutex freigeben
|
||||||
syncMutex.unlock()
|
syncMutex.unlock()
|
||||||
}
|
}
|
||||||
@@ -753,54 +823,71 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||||
// Sync logic requires nested conditions for comprehensive error handling and state management
|
// Sync logic requires nested conditions for comprehensive error handling and state management
|
||||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
private fun uploadLocalNotes(
|
||||||
|
sardine: Sardine,
|
||||||
|
serverUrl: String,
|
||||||
|
onProgress: (current: Int, total: Int, noteTitle: String) -> Unit = { _, _, _ -> } // 🆕 v1.8.0
|
||||||
|
): Int {
|
||||||
var uploadedCount = 0
|
var uploadedCount = 0
|
||||||
val localNotes = storage.loadAllNotes()
|
val localNotes = storage.loadAllNotes()
|
||||||
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Zähle zu uploadende Notizen für Progress
|
||||||
|
val pendingNotes = localNotes.filter {
|
||||||
|
it.syncStatus == SyncStatus.LOCAL_ONLY || it.syncStatus == SyncStatus.PENDING
|
||||||
|
}
|
||||||
|
val totalToUpload = pendingNotes.size
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_004): Batch E-Tag Updates für Performance
|
||||||
|
val etagUpdates = mutableMapOf<String, String?>()
|
||||||
|
|
||||||
for (note in localNotes) {
|
for (note in localNotes) {
|
||||||
try {
|
try {
|
||||||
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
|
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
|
||||||
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
||||||
val notesUrl = getNotesUrl(serverUrl)
|
val notesUrl = getNotesUrl(serverUrl)
|
||||||
val noteUrl = "$notesUrl${note.id}.json"
|
val noteUrl = "$notesUrl${note.id}.json"
|
||||||
val jsonBytes = note.toJson().toByteArray()
|
|
||||||
|
// 🔧 v1.7.2 FIX (IMPL_015): Status VOR Serialisierung auf SYNCED setzen
|
||||||
|
// Verhindert dass Server-JSON "syncStatus": "PENDING" enthält
|
||||||
|
val noteToUpload = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||||
|
val jsonBytes = noteToUpload.toJson().toByteArray()
|
||||||
|
|
||||||
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
|
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
|
||||||
sardine.put(noteUrl, jsonBytes, "application/json")
|
sardine.put(noteUrl, jsonBytes, "application/json")
|
||||||
Logger.d(TAG, " ✅ Upload successful")
|
Logger.d(TAG, " ✅ Upload successful")
|
||||||
|
|
||||||
// Update sync status
|
// Lokale Kopie auch mit SYNCED speichern
|
||||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
storage.saveNote(noteToUpload)
|
||||||
storage.saveNote(updatedNote)
|
|
||||||
uploadedCount++
|
uploadedCount++
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Progress mit Notiz-Titel
|
||||||
|
onProgress(uploadedCount, totalToUpload, note.title)
|
||||||
|
|
||||||
// ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download
|
// ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download
|
||||||
// Get new E-Tag from server via PROPFIND
|
// 🔧 v1.7.2 (IMPL_004): Sammle E-Tags für Batch-Update
|
||||||
try {
|
try {
|
||||||
val uploadedResource = sardine.list(noteUrl, 0).firstOrNull()
|
val uploadedResource = sardine.list(noteUrl, 0).firstOrNull()
|
||||||
val newETag = uploadedResource?.etag
|
val newETag = uploadedResource?.etag
|
||||||
|
etagUpdates["etag_json_${note.id}"] = newETag
|
||||||
if (newETag != null) {
|
if (newETag != null) {
|
||||||
prefs.edit().putString("etag_json_${note.id}", newETag).apply()
|
Logger.d(TAG, " ⚡ Queued E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}")
|
||||||
Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}")
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: invalidate if server doesn't provide E-Tag
|
Logger.d(TAG, " ⚠️ No E-Tag from server, will invalidate")
|
||||||
prefs.edit().remove("etag_json_${note.id}").apply()
|
|
||||||
Logger.d(TAG, " ⚠️ No E-Tag from server, invalidated cache")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w(TAG, " ⚠️ Failed to refresh E-Tag: ${e.message}")
|
Logger.w(TAG, " ⚠️ Failed to get E-Tag: ${e.message}")
|
||||||
prefs.edit().remove("etag_json_${note.id}").apply()
|
etagUpdates["etag_json_${note.id}"] = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Markdown-Export (NEU in v1.2.0)
|
// 2. Markdown-Export (NEU in v1.2.0)
|
||||||
// Läuft NACH erfolgreichem JSON-Upload
|
// Läuft NACH erfolgreichem JSON-Upload
|
||||||
if (markdownExportEnabled) {
|
if (markdownExportEnabled) {
|
||||||
try {
|
try {
|
||||||
exportToMarkdown(sardine, serverUrl, note)
|
exportToMarkdown(sardine, serverUrl, noteToUpload)
|
||||||
Logger.d(TAG, " 📝 MD exported: ${note.title}")
|
Logger.d(TAG, " 📝 MD exported: ${noteToUpload.title}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
|
Logger.e(TAG, "MD-Export failed for ${noteToUpload.id}: ${e.message}")
|
||||||
// Kein throw! JSON-Sync darf nicht blockiert werden
|
// Kein throw! JSON-Sync darf nicht blockiert werden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,9 +900,45 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_004): Batch-Update aller E-Tags in einer Operation
|
||||||
|
if (etagUpdates.isNotEmpty()) {
|
||||||
|
batchUpdateETags(etagUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
return uploadedCount
|
return uploadedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔧 v1.7.2 (IMPL_004): Batch-Update von E-Tags
|
||||||
|
*
|
||||||
|
* Schreibt alle E-Tags in einer einzelnen I/O-Operation statt einzeln.
|
||||||
|
* Performance-Gewinn: ~50-100ms pro Batch (statt N × apply())
|
||||||
|
*
|
||||||
|
* @param updates Map von E-Tag Keys zu Values (null = remove)
|
||||||
|
*/
|
||||||
|
private fun batchUpdateETags(updates: Map<String, String?>) {
|
||||||
|
try {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
var putCount = 0
|
||||||
|
var removeCount = 0
|
||||||
|
|
||||||
|
updates.forEach { (key, value) ->
|
||||||
|
if (value != null) {
|
||||||
|
editor.putString(key, value)
|
||||||
|
putCount++
|
||||||
|
} else {
|
||||||
|
editor.remove(key)
|
||||||
|
removeCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.apply()
|
||||||
|
Logger.d(TAG, "⚡ Batch-updated E-Tags: $putCount saved, $removeCount removed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to batch-update E-Tags", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
||||||
*
|
*
|
||||||
@@ -927,71 +1050,157 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
|
|
||||||
val mdUrl = getMarkdownUrl(serverUrl)
|
try {
|
||||||
|
val mdUrl = getMarkdownUrl(serverUrl)
|
||||||
|
|
||||||
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
|
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
|
||||||
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
||||||
|
|
||||||
// Hole ALLE lokalen Notizen (inklusive SYNCED)
|
// Hole ALLE lokalen Notizen (inklusive SYNCED)
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
val totalCount = allNotes.size
|
val totalCount = allNotes.size
|
||||||
var exportedCount = 0
|
var exportedCount = 0
|
||||||
|
|
||||||
// Track used filenames to handle duplicates
|
// Track used filenames to handle duplicates
|
||||||
val usedFilenames = mutableSetOf<String>()
|
val usedFilenames = mutableSetOf<String>()
|
||||||
|
|
||||||
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
||||||
|
|
||||||
allNotes.forEachIndexed { index, note ->
|
allNotes.forEachIndexed { index, note ->
|
||||||
try {
|
try {
|
||||||
// Progress-Callback
|
// Progress-Callback
|
||||||
onProgress(index + 1, totalCount)
|
onProgress(index + 1, totalCount)
|
||||||
|
|
||||||
// Eindeutiger Filename (mit Duplikat-Handling)
|
// Eindeutiger Filename (mit Duplikat-Handling)
|
||||||
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
|
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
|
||||||
val noteUrl = "$mdUrl/$filename"
|
val noteUrl = "$mdUrl/$filename"
|
||||||
|
|
||||||
// Konvertiere zu Markdown
|
// Konvertiere zu Markdown
|
||||||
val mdContent = note.toMarkdown().toByteArray()
|
val mdContent = note.toMarkdown().toByteArray()
|
||||||
|
|
||||||
// Upload (überschreibt falls vorhanden)
|
// Upload (überschreibt falls vorhanden)
|
||||||
sardine.put(noteUrl, mdContent, "text/markdown")
|
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||||
|
|
||||||
exportedCount++
|
exportedCount++
|
||||||
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
|
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
||||||
// Continue mit nächster Note (keine Abbruch bei Einzelfehlern)
|
// Continue mit nächster Note (keine Abbruch bei Einzelfehlern)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes")
|
||||||
|
|
||||||
|
// ⚡ v1.3.1: Set lastSyncTimestamp to enable timestamp-based skip on next sync
|
||||||
|
// This prevents re-downloading all MD files on the first manual sync after initial export
|
||||||
|
if (exportedCount > 0) {
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
|
||||||
|
Logger.d(TAG, "💾 Set lastSyncTimestamp after initial export (enables fast next sync)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext exportedCount
|
||||||
|
} finally {
|
||||||
|
// 🐛 FIX: Connection Leak — SafeSardineWrapper explizit schließen
|
||||||
|
sardine.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes")
|
|
||||||
|
|
||||||
// ⚡ v1.3.1: Set lastSyncTimestamp to enable timestamp-based skip on next sync
|
|
||||||
// This prevents re-downloading all MD files on the first manual sync after initial export
|
|
||||||
if (exportedCount > 0) {
|
|
||||||
val timestamp = System.currentTimeMillis()
|
|
||||||
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
|
|
||||||
Logger.d(TAG, "💾 Set lastSyncTimestamp after initial export (enables fast next sync)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext exportedCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class DownloadResult(
|
private data class DownloadResult(
|
||||||
val downloadedCount: Int,
|
val downloadedCount: Int,
|
||||||
val conflictCount: Int
|
val conflictCount: Int,
|
||||||
|
val deletedOnServerCount: Int = 0 // 🆕 v1.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
/**
|
||||||
|
* 🆕 v1.8.0: Erkennt Notizen, die auf dem Server gelöscht wurden
|
||||||
|
* 🔧 v1.8.1: Safety-Guard gegen leere serverNoteIds (verhindert Massenlöschung)
|
||||||
|
*
|
||||||
|
* Keine zusätzlichen HTTP-Requests! Nutzt die bereits geladene
|
||||||
|
* serverNoteIds-Liste aus dem PROPFIND-Request.
|
||||||
|
*
|
||||||
|
* Prüft ALLE Notizen (Notes + Checklists), da beide als
|
||||||
|
* JSON in /notes/{id}.json gespeichert werden.
|
||||||
|
* NoteType (NOTE vs CHECKLIST) spielt keine Rolle für die Detection.
|
||||||
|
*
|
||||||
|
* @param serverNoteIds Set aller Note-IDs auf dem Server (aus PROPFIND)
|
||||||
|
* @param localNotes Alle lokalen Notizen
|
||||||
|
* @return Anzahl der als DELETED_ON_SERVER markierten Notizen
|
||||||
|
*/
|
||||||
|
private fun detectServerDeletions(
|
||||||
|
serverNoteIds: Set<String>,
|
||||||
|
localNotes: List<Note>
|
||||||
|
): Int {
|
||||||
|
val syncedNotes = localNotes.filter { it.syncStatus == SyncStatus.SYNCED }
|
||||||
|
|
||||||
|
// 🔧 v1.8.1 SAFETY: Wenn serverNoteIds leer ist, NIEMALS Notizen als gelöscht markieren!
|
||||||
|
// Ein leeres Set bedeutet wahrscheinlich: PROPFIND fehlgeschlagen, /notes/ nicht gefunden,
|
||||||
|
// oder Netzwerkfehler — NICHT dass alle Notizen gelöscht wurden.
|
||||||
|
if (serverNoteIds.isEmpty()) {
|
||||||
|
Logger.w(TAG, "⚠️ detectServerDeletions: serverNoteIds is EMPTY! " +
|
||||||
|
"Skipping deletion detection to prevent data loss. " +
|
||||||
|
"localSynced=${syncedNotes.size}, localTotal=${localNotes.size}")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 v1.8.1 SAFETY: Wenn ALLE lokalen SYNCED-Notizen als gelöscht erkannt werden,
|
||||||
|
// ist das fast sicher ein Fehler (z.B. falsche Server-URL oder partieller PROPFIND).
|
||||||
|
// Maximal 50% der Notizen dürfen als gelöscht markiert werden.
|
||||||
|
val potentialDeletions = syncedNotes.count { it.id !in serverNoteIds }
|
||||||
|
if (syncedNotes.size > 1 && potentialDeletions == syncedNotes.size) {
|
||||||
|
Logger.e(TAG, "🚨 detectServerDeletions: ALL ${syncedNotes.size} synced notes " +
|
||||||
|
"would be marked as deleted! This is almost certainly a bug. " +
|
||||||
|
"serverNoteIds=${serverNoteIds.size}. ABORTING deletion detection.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0 (IMPL_022): Statistik-Log für Debugging
|
||||||
|
Logger.d(TAG, "🔍 detectServerDeletions: " +
|
||||||
|
"serverNotes=${serverNoteIds.size}, " +
|
||||||
|
"localSynced=${syncedNotes.size}, " +
|
||||||
|
"localTotal=${localNotes.size}")
|
||||||
|
|
||||||
|
var deletedCount = 0
|
||||||
|
syncedNotes.forEach { note ->
|
||||||
|
// Nur SYNCED-Notizen prüfen:
|
||||||
|
// - LOCAL_ONLY: War nie auf Server → irrelevant
|
||||||
|
// - PENDING: Soll hochgeladen werden → nicht überschreiben
|
||||||
|
// - CONFLICT: Wird separat behandelt
|
||||||
|
// - DELETED_ON_SERVER: Bereits markiert
|
||||||
|
if (note.id !in serverNoteIds) {
|
||||||
|
val updatedNote = note.copy(syncStatus = SyncStatus.DELETED_ON_SERVER)
|
||||||
|
storage.saveNote(updatedNote)
|
||||||
|
deletedCount++
|
||||||
|
|
||||||
|
Logger.d(TAG, "🗑️ Note '${note.title}' (${note.id}) " +
|
||||||
|
"was deleted on server, marked as DELETED_ON_SERVER")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
Logger.d(TAG, "📊 Server deletion detection complete: " +
|
||||||
|
"$deletedCount of ${syncedNotes.size} synced notes deleted on server")
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress(
|
||||||
|
"NestedBlockDepth",
|
||||||
|
"LoopWithTooManyJumpStatements",
|
||||||
|
"LongMethod",
|
||||||
|
"ComplexMethod"
|
||||||
|
)
|
||||||
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
|
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
|
||||||
|
// TODO: Refactor into smaller functions in v1.9.0/v2.0.0 (see LINT_DETEKT_FEHLER_BEHEBUNG_PLAN.md)
|
||||||
private fun downloadRemoteNotes(
|
private fun downloadRemoteNotes(
|
||||||
sardine: Sardine,
|
sardine: Sardine,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
includeRootFallback: Boolean = false, // 🆕 v1.2.2: Only for restore from server
|
includeRootFallback: Boolean = false, // 🆕 v1.2.2: Only for restore from server
|
||||||
forceOverwrite: Boolean = false, // 🆕 v1.3.0: For OVERWRITE_DUPLICATES mode
|
forceOverwrite: Boolean = false, // 🆕 v1.3.0: For OVERWRITE_DUPLICATES mode
|
||||||
deletionTracker: DeletionTracker = storage.loadDeletionTracker() // 🆕 v1.3.0: Allow passing fresh tracker
|
deletionTracker: DeletionTracker = storage.loadDeletionTracker(), // 🆕 v1.3.0: Allow passing fresh tracker
|
||||||
|
onProgress: (current: Int, total: Int, fileName: String) -> Unit = { _, _, _ -> } // 🆕 v1.8.0
|
||||||
): DownloadResult {
|
): DownloadResult {
|
||||||
var downloadedCount = 0
|
var downloadedCount = 0
|
||||||
var conflictCount = 0
|
var conflictCount = 0
|
||||||
@@ -1005,6 +1214,9 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Use provided deletion tracker (allows fresh tracker from restore)
|
// Use provided deletion tracker (allows fresh tracker from restore)
|
||||||
var trackerModified = false
|
var trackerModified = false
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Collect server note IDs for deletion detection
|
||||||
|
val serverNoteIds = mutableSetOf<String>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🆕 PHASE 1: Download from /notes/ (new structure v1.2.1+)
|
// 🆕 PHASE 1: Download from /notes/ (new structure v1.2.1+)
|
||||||
val notesUrl = getNotesUrl(serverUrl)
|
val notesUrl = getNotesUrl(serverUrl)
|
||||||
@@ -1020,8 +1232,18 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") }
|
val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") }
|
||||||
Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server")
|
Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server")
|
||||||
|
|
||||||
for (resource in jsonFiles) {
|
// 🆕 v1.8.0: Extract server note IDs
|
||||||
|
jsonFiles.forEach { resource ->
|
||||||
|
val noteId = resource.name.removeSuffix(".json")
|
||||||
|
serverNoteIds.add(noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.8.0: PHASE 1A - Collect Download Tasks
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
val downloadTasks = mutableListOf<DownloadTask>()
|
||||||
|
|
||||||
|
for (resource in jsonFiles) {
|
||||||
val noteId = resource.name.removeSuffix(".json")
|
val noteId = resource.name.removeSuffix(".json")
|
||||||
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
|
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
|
||||||
|
|
||||||
@@ -1098,60 +1320,130 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
Logger.d(TAG, " 📥 Downloading $noteId: $downloadReason")
|
Logger.d(TAG, " 📥 Downloading $noteId: $downloadReason")
|
||||||
|
|
||||||
// Download and process
|
// 🆕 v1.8.0: Add to download tasks
|
||||||
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
downloadTasks.add(DownloadTask(
|
||||||
val remoteNote = Note.fromJson(jsonContent) ?: continue
|
noteId = noteId,
|
||||||
|
url = noteUrl,
|
||||||
|
resource = resource,
|
||||||
|
serverETag = serverETag,
|
||||||
|
serverModified = serverModified
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
processedIds.add(remoteNote.id) // 🆕 Mark as processed
|
Logger.d(TAG, " 📋 ${downloadTasks.size} files to download, $skippedDeleted skipped (deleted), " +
|
||||||
|
"$skippedUnchanged skipped (unchanged)")
|
||||||
|
|
||||||
// Note: localNote was already loaded above for existence check
|
// ════════════════════════════════════════════════════════════════
|
||||||
when {
|
// 🆕 v1.8.0: PHASE 1B - Parallel Download
|
||||||
localNote == null -> {
|
// ════════════════════════════════════════════════════════════════
|
||||||
// New note from server
|
if (downloadTasks.isNotEmpty()) {
|
||||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
// Konfigurierbare Parallelität aus Settings
|
||||||
downloadedCount++
|
val maxParallel = prefs.getInt(
|
||||||
Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
|
Constants.KEY_MAX_PARALLEL_DOWNLOADS,
|
||||||
|
Constants.DEFAULT_MAX_PARALLEL_DOWNLOADS
|
||||||
|
)
|
||||||
|
|
||||||
// ⚡ Cache E-Tag for next sync
|
val downloader = ParallelDownloader(
|
||||||
if (serverETag != null) {
|
sardine = sardine,
|
||||||
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
|
maxParallelDownloads = maxParallel
|
||||||
}
|
)
|
||||||
}
|
|
||||||
forceOverwrite -> {
|
|
||||||
// OVERWRITE mode: Always replace regardless of timestamps
|
|
||||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
|
||||||
downloadedCount++
|
|
||||||
Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}")
|
|
||||||
|
|
||||||
// ⚡ Cache E-Tag for next sync
|
downloader.onProgress = { completed, total, currentFile ->
|
||||||
if (serverETag != null) {
|
onProgress(completed, total, currentFile ?: "?")
|
||||||
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
localNote.updatedAt < remoteNote.updatedAt -> {
|
|
||||||
// Remote is newer
|
|
||||||
if (localNote.syncStatus == SyncStatus.PENDING) {
|
|
||||||
// Conflict detected
|
|
||||||
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
|
||||||
conflictCount++
|
|
||||||
} else {
|
|
||||||
// Safe to overwrite
|
|
||||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
|
||||||
downloadedCount++
|
|
||||||
Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}")
|
|
||||||
|
|
||||||
// ⚡ Cache E-Tag for next sync
|
val downloadResults = runBlocking {
|
||||||
if (serverETag != null) {
|
downloader.downloadAll(downloadTasks)
|
||||||
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.8.0: PHASE 1C - Process Results
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
Logger.d(TAG, " 🔄 Processing ${downloadResults.size} download results")
|
||||||
|
|
||||||
|
// Batch-collect E-Tags for single write
|
||||||
|
val etagUpdates = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
for (result in downloadResults) {
|
||||||
|
when (result) {
|
||||||
|
is DownloadTaskResult.Success -> {
|
||||||
|
val remoteNote = Note.fromJson(result.content)
|
||||||
|
if (remoteNote == null) {
|
||||||
|
Logger.w(TAG, " ⚠️ Failed to parse JSON: ${result.noteId}")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processedIds.add(remoteNote.id)
|
||||||
|
val localNote = storage.loadNote(remoteNote.id)
|
||||||
|
|
||||||
|
when {
|
||||||
|
localNote == null -> {
|
||||||
|
// New note from server
|
||||||
|
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||||
|
downloadedCount++
|
||||||
|
Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
|
||||||
|
|
||||||
|
// ⚡ Batch E-Tag for later
|
||||||
|
if (result.etag != null) {
|
||||||
|
etagUpdates["etag_json_${result.noteId}"] = result.etag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forceOverwrite -> {
|
||||||
|
// OVERWRITE mode: Always replace regardless of timestamps
|
||||||
|
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||||
|
downloadedCount++
|
||||||
|
Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}")
|
||||||
|
|
||||||
|
if (result.etag != null) {
|
||||||
|
etagUpdates["etag_json_${result.noteId}"] = result.etag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localNote.updatedAt < remoteNote.updatedAt -> {
|
||||||
|
// Remote is newer
|
||||||
|
if (localNote.syncStatus == SyncStatus.PENDING) {
|
||||||
|
// Conflict detected
|
||||||
|
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
||||||
|
conflictCount++
|
||||||
|
Logger.w(TAG, " ⚠️ Conflict: ${remoteNote.id}")
|
||||||
|
} else {
|
||||||
|
// Safe to overwrite
|
||||||
|
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||||
|
downloadedCount++
|
||||||
|
Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}")
|
||||||
|
|
||||||
|
if (result.etag != null) {
|
||||||
|
etagUpdates["etag_json_${result.noteId}"] = result.etag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: Local is newer or same → skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DownloadTaskResult.Failure -> {
|
||||||
|
Logger.e(TAG, " ❌ Download failed: ${result.noteId} - ${result.error.message}")
|
||||||
|
// Fehlerhafte Downloads nicht als verarbeitet markieren
|
||||||
|
// → werden beim nächsten Sync erneut versucht
|
||||||
|
}
|
||||||
|
is DownloadTaskResult.Skipped -> {
|
||||||
|
Logger.d(TAG, " ⏭️ Skipped: ${result.noteId} - ${result.reason}")
|
||||||
|
processedIds.add(result.noteId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚡ Batch-save E-Tags (IMPL_004 optimization)
|
||||||
|
if (etagUpdates.isNotEmpty()) {
|
||||||
|
prefs.edit().apply {
|
||||||
|
etagUpdates.forEach { (key, value) -> putString(key, value) }
|
||||||
|
}.apply()
|
||||||
|
Logger.d(TAG, " 💾 Batch-saved ${etagUpdates.size} E-Tags")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
TAG,
|
TAG,
|
||||||
" 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), " +
|
" 📊 Phase 1: $downloadedCount downloaded, $conflictCount conflicts, " +
|
||||||
"$skippedUnchanged skipped (unchanged)"
|
"$skippedDeleted skipped (deleted), $skippedUnchanged skipped (unchanged)"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
|
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
|
||||||
@@ -1260,8 +1552,16 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
Logger.d(TAG, "💾 Deletion tracker updated")
|
Logger.d(TAG, "💾 Deletion tracker updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Server-Deletions erkennen (nach Downloads)
|
||||||
|
val allLocalNotes = storage.loadAllNotes()
|
||||||
|
val deletedOnServerCount = detectServerDeletions(serverNoteIds, allLocalNotes)
|
||||||
|
|
||||||
|
if (deletedOnServerCount > 0) {
|
||||||
|
Logger.d(TAG, "$deletedOnServerCount note(s) detected as deleted on server")
|
||||||
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "📊 Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted")
|
Logger.d(TAG, "📊 Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted")
|
||||||
return DownloadResult(downloadedCount, conflictCount)
|
return DownloadResult(downloadedCount, conflictCount, deletedOnServerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLastSyncTimestamp() {
|
private fun saveLastSyncTimestamp() {
|
||||||
@@ -1450,58 +1750,63 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val okHttpClient = OkHttpClient.Builder().build()
|
val okHttpClient = OkHttpClient.Builder().build()
|
||||||
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
|
|
||||||
val mdUrl = getMarkdownUrl(serverUrl)
|
try {
|
||||||
|
val mdUrl = getMarkdownUrl(serverUrl)
|
||||||
|
|
||||||
// Check if notes-md/ exists
|
// Check if notes-md/ exists
|
||||||
if (!sardine.exists(mdUrl)) {
|
if (!sardine.exists(mdUrl)) {
|
||||||
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
||||||
return@withContext 0
|
return@withContext 0
|
||||||
}
|
|
||||||
|
|
||||||
val localNotes = storage.loadAllNotes()
|
|
||||||
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
|
|
||||||
var importedCount = 0
|
|
||||||
|
|
||||||
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
|
|
||||||
|
|
||||||
for (resource in mdResources) {
|
|
||||||
try {
|
|
||||||
// Download MD-File
|
|
||||||
val mdContent = sardine.get(resource.href.toString())
|
|
||||||
.bufferedReader().use { it.readText() }
|
|
||||||
|
|
||||||
// Parse zu Note
|
|
||||||
val mdNote = Note.fromMarkdown(mdContent) ?: continue
|
|
||||||
|
|
||||||
val localNote = localNotes.find { it.id == mdNote.id }
|
|
||||||
|
|
||||||
// Konfliktauflösung: Last-Write-Wins
|
|
||||||
when {
|
|
||||||
localNote == null -> {
|
|
||||||
// Neue Notiz vom Desktop
|
|
||||||
storage.saveNote(mdNote)
|
|
||||||
importedCount++
|
|
||||||
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
|
|
||||||
}
|
|
||||||
mdNote.updatedAt > localNote.updatedAt -> {
|
|
||||||
// Desktop-Version ist neuer (Last-Write-Wins)
|
|
||||||
storage.saveNote(mdNote)
|
|
||||||
importedCount++
|
|
||||||
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
|
|
||||||
}
|
|
||||||
// Sonst: Lokale Version behalten
|
|
||||||
else -> {
|
|
||||||
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, "Failed to import ${resource.name}", e)
|
|
||||||
// Continue with other files
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
|
val localNotes = storage.loadAllNotes()
|
||||||
importedCount
|
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
|
||||||
|
var importedCount = 0
|
||||||
|
|
||||||
|
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
|
||||||
|
|
||||||
|
for (resource in mdResources) {
|
||||||
|
try {
|
||||||
|
// Download MD-File
|
||||||
|
val mdContent = sardine.get(resource.href.toString())
|
||||||
|
.bufferedReader().use { it.readText() }
|
||||||
|
|
||||||
|
// Parse zu Note
|
||||||
|
val mdNote = Note.fromMarkdown(mdContent) ?: continue
|
||||||
|
|
||||||
|
val localNote = localNotes.find { it.id == mdNote.id }
|
||||||
|
|
||||||
|
// Konfliktauflösung: Last-Write-Wins
|
||||||
|
when {
|
||||||
|
localNote == null -> {
|
||||||
|
// Neue Notiz vom Desktop
|
||||||
|
storage.saveNote(mdNote)
|
||||||
|
importedCount++
|
||||||
|
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
|
||||||
|
}
|
||||||
|
mdNote.updatedAt > localNote.updatedAt -> {
|
||||||
|
// Desktop-Version ist neuer (Last-Write-Wins)
|
||||||
|
storage.saveNote(mdNote)
|
||||||
|
importedCount++
|
||||||
|
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
|
||||||
|
}
|
||||||
|
// Sonst: Lokale Version behalten
|
||||||
|
else -> {
|
||||||
|
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to import ${resource.name}", e)
|
||||||
|
// Continue with other files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
|
||||||
|
importedCount
|
||||||
|
} finally {
|
||||||
|
// 🐛 FIX: Connection Leak — SafeSardineWrapper explizit schließen
|
||||||
|
sardine.close()
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Markdown sync failed", e)
|
Logger.e(TAG, "Markdown sync failed", e)
|
||||||
@@ -1560,8 +1865,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
|
val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
|
||||||
Logger.d(TAG, " Downloaded ${mdContent.length} chars")
|
Logger.d(TAG, " Downloaded ${mdContent.length} chars")
|
||||||
|
|
||||||
// Parse to Note
|
// 🔧 v1.7.2 (IMPL_014): Server mtime übergeben für korrekte Timestamp-Sync
|
||||||
val mdNote = Note.fromMarkdown(mdContent)
|
val mdNote = Note.fromMarkdown(mdContent, serverModifiedTime)
|
||||||
if (mdNote == null) {
|
if (mdNote == null) {
|
||||||
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
||||||
continue
|
continue
|
||||||
@@ -1609,7 +1914,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
|
// Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
|
||||||
val contentChanged = localNote != null && (
|
val contentChanged = localNote != null && (
|
||||||
mdNote.content != localNote.content ||
|
mdNote.content != localNote.content ||
|
||||||
mdNote.title != localNote.title
|
mdNote.title != localNote.title ||
|
||||||
|
mdNote.checklistItems != localNote.checklistItems
|
||||||
)
|
)
|
||||||
|
|
||||||
if (contentChanged) {
|
if (contentChanged) {
|
||||||
@@ -1636,16 +1942,15 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
|
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren!
|
// 🔧 v1.7.2 (IMPL_014): Content geändert → Importieren UND als PENDING markieren!
|
||||||
|
// PENDING triggert JSON-Upload beim nächsten Sync-Zyklus
|
||||||
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
|
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
|
||||||
// Inhalt wurde extern geändert ohne YAML-Update → mit aktuellem Timestamp importieren
|
|
||||||
val newTimestamp = System.currentTimeMillis()
|
|
||||||
storage.saveNote(mdNote.copy(
|
storage.saveNote(mdNote.copy(
|
||||||
updatedAt = newTimestamp,
|
updatedAt = serverModifiedTime, // Server mtime verwenden
|
||||||
syncStatus = SyncStatus.SYNCED
|
syncStatus = SyncStatus.PENDING // ⬅️ KRITISCH: Triggert JSON-Upload
|
||||||
))
|
))
|
||||||
importedCount++
|
importedCount++
|
||||||
Logger.d(TAG, " ✅ Imported changed content (YAML timestamp outdated): ${mdNote.title}")
|
Logger.d(TAG, " ✅ Imported changed content (marked PENDING for JSON sync): ${mdNote.title}")
|
||||||
}
|
}
|
||||||
mdNote.updatedAt > localNote.updatedAt -> {
|
mdNote.updatedAt > localNote.updatedAt -> {
|
||||||
// Markdown has newer YAML timestamp
|
// Markdown has newer YAML timestamp
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.dettmer.simplenotes.sync.parallel
|
||||||
|
|
||||||
|
import com.thegrizzlylabs.sardineandroid.DavResource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Repräsentiert einen einzelnen Download-Task
|
||||||
|
*
|
||||||
|
* @param noteId Die ID der Notiz (ohne .json Extension)
|
||||||
|
* @param url Vollständige URL zur JSON-Datei
|
||||||
|
* @param resource WebDAV-Resource mit Metadaten
|
||||||
|
* @param serverETag E-Tag vom Server (für Caching)
|
||||||
|
* @param serverModified Letztes Änderungsdatum vom Server (Unix timestamp)
|
||||||
|
*/
|
||||||
|
data class DownloadTask(
|
||||||
|
val noteId: String,
|
||||||
|
val url: String,
|
||||||
|
val resource: DavResource,
|
||||||
|
val serverETag: String?,
|
||||||
|
val serverModified: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Ergebnis eines einzelnen Downloads
|
||||||
|
*
|
||||||
|
* Sealed class für typ-sichere Verarbeitung von Download-Ergebnissen.
|
||||||
|
* Jeder Download kann erfolgreich sein, fehlschlagen oder übersprungen werden.
|
||||||
|
*/
|
||||||
|
sealed class DownloadTaskResult {
|
||||||
|
/**
|
||||||
|
* Download erfolgreich abgeschlossen
|
||||||
|
*
|
||||||
|
* @param noteId Die ID der heruntergeladenen Notiz
|
||||||
|
* @param content JSON-Inhalt der Notiz
|
||||||
|
* @param etag E-Tag vom Server (für zukünftiges Caching)
|
||||||
|
*/
|
||||||
|
data class Success(
|
||||||
|
val noteId: String,
|
||||||
|
val content: String,
|
||||||
|
val etag: String?
|
||||||
|
) : DownloadTaskResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download fehlgeschlagen
|
||||||
|
*
|
||||||
|
* @param noteId Die ID der Notiz, die nicht heruntergeladen werden konnte
|
||||||
|
* @param error Der aufgetretene Fehler
|
||||||
|
*/
|
||||||
|
data class Failure(
|
||||||
|
val noteId: String,
|
||||||
|
val error: Throwable
|
||||||
|
) : DownloadTaskResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download übersprungen (z.B. wegen gelöschter Notiz)
|
||||||
|
*
|
||||||
|
* @param noteId Die ID der übersprungenen Notiz
|
||||||
|
* @param reason Grund für das Überspringen
|
||||||
|
*/
|
||||||
|
data class Skipped(
|
||||||
|
val noteId: String,
|
||||||
|
val reason: String
|
||||||
|
) : DownloadTaskResult()
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package dev.dettmer.simplenotes.sync.parallel
|
||||||
|
|
||||||
|
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Paralleler Download-Handler für Notizen
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Konfigurierbare max. parallele Downloads (default: 5)
|
||||||
|
* - Graceful Error-Handling (einzelne Fehler stoppen nicht den ganzen Sync)
|
||||||
|
* - Progress-Callback für UI-Updates
|
||||||
|
* - Retry-Logic für transiente Fehler mit Exponential Backoff
|
||||||
|
*
|
||||||
|
* Performance:
|
||||||
|
* - 100 Notizen: ~20s → ~4s (5x schneller)
|
||||||
|
* - 50 Notizen: ~10s → ~2s
|
||||||
|
*
|
||||||
|
* @param sardine WebDAV-Client für Downloads
|
||||||
|
* @param maxParallelDownloads Maximale Anzahl gleichzeitiger Downloads (1-10)
|
||||||
|
* @param retryCount Anzahl der Wiederholungsversuche bei Fehlern
|
||||||
|
*/
|
||||||
|
class ParallelDownloader(
|
||||||
|
private val sardine: Sardine,
|
||||||
|
private val maxParallelDownloads: Int = DEFAULT_MAX_PARALLEL,
|
||||||
|
private val retryCount: Int = DEFAULT_RETRY_COUNT
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ParallelDownloader"
|
||||||
|
const val DEFAULT_MAX_PARALLEL = 5
|
||||||
|
const val DEFAULT_RETRY_COUNT = 2
|
||||||
|
private const val RETRY_DELAY_MS = 500L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download-Progress Callback
|
||||||
|
*
|
||||||
|
* @param completed Anzahl abgeschlossener Downloads
|
||||||
|
* @param total Gesamtanzahl Downloads
|
||||||
|
* @param currentFile Aktueller Dateiname (optional)
|
||||||
|
*/
|
||||||
|
var onProgress: ((completed: Int, total: Int, currentFile: String?) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt parallele Downloads aus
|
||||||
|
*
|
||||||
|
* Die Downloads werden mit einem Semaphore begrenzt, um Server-Überlastung
|
||||||
|
* zu vermeiden. Jeder Download wird unabhängig behandelt - Fehler in einem
|
||||||
|
* Download stoppen nicht die anderen.
|
||||||
|
*
|
||||||
|
* @param tasks Liste der Download-Tasks
|
||||||
|
* @return Liste der Ergebnisse (Success, Failure, Skipped)
|
||||||
|
*/
|
||||||
|
suspend fun downloadAll(
|
||||||
|
tasks: List<DownloadTask>
|
||||||
|
): List<DownloadTaskResult> = coroutineScope {
|
||||||
|
|
||||||
|
if (tasks.isEmpty()) {
|
||||||
|
Logger.d(TAG, "⏭️ No tasks to download")
|
||||||
|
return@coroutineScope emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "🚀 Starting parallel download: ${tasks.size} tasks, max $maxParallelDownloads concurrent")
|
||||||
|
|
||||||
|
val semaphore = Semaphore(maxParallelDownloads)
|
||||||
|
val completedCount = AtomicInteger(0)
|
||||||
|
val totalCount = tasks.size
|
||||||
|
|
||||||
|
val jobs = tasks.map { task ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
semaphore.withPermit {
|
||||||
|
val result = downloadWithRetry(task)
|
||||||
|
|
||||||
|
// Progress Update
|
||||||
|
val completed = completedCount.incrementAndGet()
|
||||||
|
onProgress?.invoke(completed, totalCount, task.noteId)
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warte auf alle Downloads
|
||||||
|
val results = jobs.awaitAll()
|
||||||
|
|
||||||
|
// Statistiken loggen
|
||||||
|
val successCount = results.count { it is DownloadTaskResult.Success }
|
||||||
|
val failureCount = results.count { it is DownloadTaskResult.Failure }
|
||||||
|
val skippedCount = results.count { it is DownloadTaskResult.Skipped }
|
||||||
|
|
||||||
|
Logger.d(TAG, "📊 Download complete: $successCount success, $failureCount failed, $skippedCount skipped")
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download mit Retry-Logic und Exponential Backoff
|
||||||
|
*
|
||||||
|
* Versucht den Download bis zu (retryCount + 1) mal. Bei jedem Fehlversuch
|
||||||
|
* wird exponentiell länger gewartet (500ms, 1000ms, 1500ms, ...).
|
||||||
|
*
|
||||||
|
* @param task Der Download-Task
|
||||||
|
* @return Ergebnis des Downloads (Success oder Failure)
|
||||||
|
*/
|
||||||
|
private suspend fun downloadWithRetry(task: DownloadTask): DownloadTaskResult {
|
||||||
|
var lastError: Throwable? = null
|
||||||
|
|
||||||
|
repeat(retryCount + 1) { attempt ->
|
||||||
|
try {
|
||||||
|
val content = sardine.get(task.url).bufferedReader().use { it.readText() }
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Downloaded ${task.noteId} (attempt ${attempt + 1})")
|
||||||
|
|
||||||
|
return DownloadTaskResult.Success(
|
||||||
|
noteId = task.noteId,
|
||||||
|
content = content,
|
||||||
|
etag = task.serverETag
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastError = e
|
||||||
|
Logger.w(TAG, "⚠️ Download failed ${task.noteId} (attempt ${attempt + 1}): ${e.message}")
|
||||||
|
|
||||||
|
// Retry nach Delay (außer beim letzten Versuch)
|
||||||
|
if (attempt < retryCount) {
|
||||||
|
delay(RETRY_DELAY_MS * (attempt + 1)) // Exponential backoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.e(TAG, "❌ Download failed after ${retryCount + 1} attempts: ${task.noteId}")
|
||||||
|
return DownloadTaskResult.Failure(task.noteId, lastError ?: Exception("Unknown error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,18 @@ class ComposeNoteEditorActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0 (IMPL_025): Reload Checklist-State falls Widget Änderungen gemacht hat.
|
||||||
|
*
|
||||||
|
* Wenn die Activity aus dem Hintergrund zurückkehrt (z.B. nach Widget-Toggle),
|
||||||
|
* wird der aktuelle Note-Stand von Disk geladen und der ViewModel-State
|
||||||
|
* für Checklist-Items aktualisiert.
|
||||||
|
*/
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
viewModel.reloadFromStorage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -22,6 +23,8 @@ import kotlinx.coroutines.launch
|
|||||||
*
|
*
|
||||||
* Native Compose-Implementierung ohne externe Dependencies
|
* Native Compose-Implementierung ohne externe Dependencies
|
||||||
* v1.5.0: NoteEditor Redesign
|
* v1.5.0: NoteEditor Redesign
|
||||||
|
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag)
|
||||||
|
* v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt)
|
||||||
*/
|
*/
|
||||||
class DragDropListState(
|
class DragDropListState(
|
||||||
private val state: LazyListState,
|
private val state: LazyListState,
|
||||||
@@ -64,11 +67,17 @@ class DragDropListState(
|
|||||||
val startOffset = draggingItem.offset + draggingItemOffset
|
val startOffset = draggingItem.offset + draggingItemOffset
|
||||||
val endOffset = startOffset + draggingItem.size
|
val endOffset = startOffset + draggingItem.size
|
||||||
|
|
||||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
// 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
|
||||||
|
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
|
||||||
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
// wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
|
||||||
middleOffset.toInt() in item.offset..item.offsetEnd &&
|
// Dies verhindert Oszillation bei Items unterschiedlicher Größe.
|
||||||
draggingItem.index != item.index
|
// Zusätzlich: Nur adjazente Items (Index ± 1) als Swap-Kandidaten.
|
||||||
|
val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||||
|
(item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) &&
|
||||||
|
run {
|
||||||
|
val targetCenter = item.offset + item.size / 2
|
||||||
|
startOffset < targetCenter && endOffset > targetCenter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetItem != null) {
|
if (targetItem != null) {
|
||||||
@@ -84,12 +93,13 @@ class DragDropListState(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||||
onMove(draggingItem.index, targetItem.index)
|
onMove(draggingItem.index, targetItem.index)
|
||||||
|
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
|
||||||
|
draggingItemIndex = targetItem.index
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onMove(draggingItem.index, targetItem.index)
|
onMove(draggingItem.index, targetItem.index)
|
||||||
|
draggingItemIndex = targetItem.index
|
||||||
}
|
}
|
||||||
|
|
||||||
draggingItemIndex = targetItem.index
|
|
||||||
} else {
|
} else {
|
||||||
val overscroll = when {
|
val overscroll = when {
|
||||||
draggingItemDraggedDelta > 0 ->
|
draggingItemDraggedDelta > 0 ->
|
||||||
@@ -111,6 +121,7 @@ class DragDropListState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UnusedPrivateProperty")
|
||||||
private val LazyListItemInfo.offsetEnd: Int
|
private val LazyListItemInfo.offsetEnd: Int
|
||||||
get() = this.offset + this.size
|
get() = this.offset + this.size
|
||||||
}
|
}
|
||||||
@@ -130,14 +141,16 @@ fun rememberDragDropListState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
fun Modifier.dragContainer(
|
fun Modifier.dragContainer(
|
||||||
dragDropState: DragDropListState,
|
dragDropState: DragDropListState,
|
||||||
itemIndex: Int
|
itemIndex: Int
|
||||||
): Modifier {
|
): Modifier {
|
||||||
return this.pointerInput(dragDropState) {
|
val currentIndex = rememberUpdatedState(itemIndex) // 🆕 v1.8.0: rememberUpdatedState statt Key
|
||||||
|
return this.pointerInput(dragDropState) { // Nur dragDropState als Key - verhindert Gesture-Restart
|
||||||
detectDragGesturesAfterLongPress(
|
detectDragGesturesAfterLongPress(
|
||||||
onDragStart = { offset ->
|
onDragStart = { offset ->
|
||||||
dragDropState.onDragStart(offset, itemIndex)
|
dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
dragDropState.onDragInterrupted()
|
dragDropState.onDragInterrupted()
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package dev.dettmer.simplenotes.ui.editor
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -18,6 +24,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Sort
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
@@ -50,14 +57,22 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||||
import dev.dettmer.simplenotes.models.NoteType
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator
|
||||||
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
|
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
|
||||||
|
import dev.dettmer.simplenotes.ui.editor.components.ChecklistSortDialog
|
||||||
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
import dev.dettmer.simplenotes.utils.showToast
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val LAYOUT_DELAY_MS = 100L
|
||||||
|
private const val ITEM_CORNER_RADIUS_DP = 8
|
||||||
|
private const val DRAGGING_ITEM_Z_INDEX = 10f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Composable for the Note Editor screen.
|
* Main Composable for the Note Editor screen.
|
||||||
*
|
*
|
||||||
@@ -80,6 +95,8 @@ fun NoteEditorScreen(
|
|||||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||||
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var showChecklistSortDialog by remember { mutableStateOf(false) } // 🔀 v1.8.0
|
||||||
|
val lastChecklistSortOption by viewModel.lastChecklistSortOption.collectAsState() // 🔀 v1.8.0
|
||||||
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -95,7 +112,7 @@ fun NoteEditorScreen(
|
|||||||
|
|
||||||
// v1.5.0: Auto-focus and show keyboard
|
// v1.5.0: Auto-focus and show keyboard
|
||||||
LaunchedEffect(uiState.isNewNote, uiState.noteType) {
|
LaunchedEffect(uiState.isNewNote, uiState.noteType) {
|
||||||
delay(100) // Wait for layout
|
delay(LAYOUT_DELAY_MS) // Wait for layout
|
||||||
when {
|
when {
|
||||||
uiState.isNewNote -> {
|
uiState.isNewNote -> {
|
||||||
// New note: focus title
|
// New note: focus title
|
||||||
@@ -215,6 +232,7 @@ fun NoteEditorScreen(
|
|||||||
items = checklistItems,
|
items = checklistItems,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
focusNewItemId = focusNewItemId,
|
focusNewItemId = focusNewItemId,
|
||||||
|
currentSortOption = lastChecklistSortOption, // 🔀 v1.8.0
|
||||||
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
|
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
|
||||||
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
|
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
|
||||||
onDelete = { id -> viewModel.deleteChecklistItem(id) },
|
onDelete = { id -> viewModel.deleteChecklistItem(id) },
|
||||||
@@ -228,6 +246,7 @@ fun NoteEditorScreen(
|
|||||||
},
|
},
|
||||||
onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
|
onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
|
||||||
onFocusHandled = { focusNewItemId = null },
|
onFocusHandled = { focusNewItemId = null },
|
||||||
|
onSortClick = { showChecklistSortDialog = true }, // 🔀 v1.8.0
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -253,6 +272,18 @@ fun NoteEditorScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔀 v1.8.0: Checklist Sort Dialog
|
||||||
|
if (showChecklistSortDialog) {
|
||||||
|
ChecklistSortDialog(
|
||||||
|
currentOption = lastChecklistSortOption,
|
||||||
|
onOptionSelected = { option ->
|
||||||
|
viewModel.sortChecklistItems(option)
|
||||||
|
showChecklistSortDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showChecklistSortDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -302,6 +333,7 @@ private fun ChecklistEditor(
|
|||||||
items: List<ChecklistItemState>,
|
items: List<ChecklistItemState>,
|
||||||
scope: kotlinx.coroutines.CoroutineScope,
|
scope: kotlinx.coroutines.CoroutineScope,
|
||||||
focusNewItemId: String?,
|
focusNewItemId: String?,
|
||||||
|
currentSortOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Sortierung
|
||||||
onTextChange: (String, String) -> Unit,
|
onTextChange: (String, String) -> Unit,
|
||||||
onCheckedChange: (String, Boolean) -> Unit,
|
onCheckedChange: (String, Boolean) -> Unit,
|
||||||
onDelete: (String) -> Unit,
|
onDelete: (String) -> Unit,
|
||||||
@@ -309,6 +341,7 @@ private fun ChecklistEditor(
|
|||||||
onAddItemAtEnd: () -> Unit,
|
onAddItemAtEnd: () -> Unit,
|
||||||
onMove: (Int, Int) -> Unit,
|
onMove: (Int, Int) -> Unit,
|
||||||
onFocusHandled: () -> Unit,
|
onFocusHandled: () -> Unit,
|
||||||
|
onSortClick: () -> Unit, // 🔀 v1.8.0
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -318,6 +351,13 @@ private fun ChecklistEditor(
|
|||||||
onMove = onMove
|
onMove = onMove
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen
|
||||||
|
val uncheckedCount = items.count { !it.isChecked }
|
||||||
|
val checkedCount = items.count { it.isChecked }
|
||||||
|
val shouldShowSeparator = currentSortOption == ChecklistSortOption.MANUAL ||
|
||||||
|
currentSortOption == ChecklistSortOption.UNCHECKED_FIRST
|
||||||
|
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -329,6 +369,11 @@ private fun ChecklistEditor(
|
|||||||
items = items,
|
items = items,
|
||||||
key = { _, item -> item.id }
|
key = { _, item -> item.id }
|
||||||
) { index, item ->
|
) { index, item ->
|
||||||
|
// 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item
|
||||||
|
if (showSeparator && index == uncheckedCount) {
|
||||||
|
CheckedItemsSeparator(checkedCount = checkedCount)
|
||||||
|
}
|
||||||
|
|
||||||
val isDragging = dragDropState.draggingItemIndex == index
|
val isDragging = dragDropState.draggingItemIndex == index
|
||||||
val elevation by animateDpAsState(
|
val elevation by animateDpAsState(
|
||||||
targetValue = if (isDragging) 8.dp else 0.dp,
|
targetValue = if (isDragging) 8.dp else 0.dp,
|
||||||
@@ -344,41 +389,69 @@ private fun ChecklistEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChecklistItemRow(
|
// 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge
|
||||||
item = item,
|
AnimatedVisibility(
|
||||||
onTextChange = { onTextChange(item.id, it) },
|
visible = true,
|
||||||
onCheckedChange = { onCheckedChange(item.id, it) },
|
enter = fadeIn() + slideInVertically(),
|
||||||
onDelete = { onDelete(item.id) },
|
exit = fadeOut() + slideOutVertically()
|
||||||
onAddNewItem = { onAddNewItemAfter(item.id) },
|
) {
|
||||||
requestFocus = shouldFocus,
|
ChecklistItemRow(
|
||||||
modifier = Modifier
|
item = item,
|
||||||
.dragContainer(dragDropState, index)
|
onTextChange = { onTextChange(item.id, it) },
|
||||||
.offset {
|
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||||
IntOffset(
|
onDelete = { onDelete(item.id) },
|
||||||
0,
|
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||||
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
requestFocus = shouldFocus,
|
||||||
|
// 🆕 v1.8.0: IMPL_023 - Drag state übergeben
|
||||||
|
isDragging = isDragging,
|
||||||
|
// 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden
|
||||||
|
isAnyItemDragging = dragDropState.draggingItemIndex != null,
|
||||||
|
// 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle
|
||||||
|
dragModifier = Modifier.dragContainer(dragDropState, index),
|
||||||
|
modifier = Modifier
|
||||||
|
.animateItem() // 🆕 v1.8.0 (IMPL_017): LazyColumn Item-Animation
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
0,
|
||||||
|
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen
|
||||||
|
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
|
||||||
|
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
.shadow(elevation, shape = RoundedCornerShape(8.dp))
|
}
|
||||||
.background(
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Item Button
|
// 🔀 v1.8.0: Add Item Button + Sort Button
|
||||||
TextButton(
|
Row(
|
||||||
onClick = onAddItemAtEnd,
|
modifier = Modifier
|
||||||
modifier = Modifier.padding(start = 8.dp)
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, end = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
TextButton(onClick = onAddItemAtEnd) {
|
||||||
imageVector = Icons.Default.Add,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = Icons.Default.Add,
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
Text(stringResource(R.string.add_item))
|
)
|
||||||
|
Text(stringResource(R.string.add_item))
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onSortClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.Sort,
|
||||||
|
contentDescription = stringResource(R.string.sort_checklist),
|
||||||
|
modifier = androidx.compose.ui.Modifier.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.models.NoteType
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
@@ -65,6 +66,10 @@ class NoteEditorViewModel(
|
|||||||
)
|
)
|
||||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||||
|
|
||||||
|
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
|
||||||
|
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
|
||||||
|
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Events
|
// Events
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -112,7 +117,8 @@ class NoteEditorViewModel(
|
|||||||
order = it.order
|
order = it.order
|
||||||
)
|
)
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
_checklistItems.value = items
|
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
|
||||||
|
_checklistItems.value = sortChecklistItems(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -163,11 +169,32 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
|
||||||
|
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
|
||||||
|
*/
|
||||||
|
private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
|
||||||
|
val unchecked = items.filter { !it.isChecked }
|
||||||
|
val checked = items.filter { it.isChecked }
|
||||||
|
|
||||||
|
return (unchecked + checked).mapIndexed { index, item ->
|
||||||
|
item.copy(order = index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
|
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
|
||||||
_checklistItems.update { items ->
|
_checklistItems.update { items ->
|
||||||
items.map { item ->
|
val updatedItems = items.map { item ->
|
||||||
if (item.id == itemId) item.copy(isChecked = isChecked) else item
|
if (item.id == itemId) item.copy(isChecked = isChecked) else item
|
||||||
}
|
}
|
||||||
|
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Auto-Sort nur bei MANUAL und UNCHECKED_FIRST
|
||||||
|
val currentSort = _lastChecklistSortOption.value
|
||||||
|
if (currentSort == ChecklistSortOption.MANUAL || currentSort == ChecklistSortOption.UNCHECKED_FIRST) {
|
||||||
|
sortChecklistItems(updatedItems)
|
||||||
|
} else {
|
||||||
|
// Bei anderen Sortierungen (alphabetisch, checked first) nicht auto-sortieren
|
||||||
|
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +235,15 @@ class NoteEditorViewModel(
|
|||||||
|
|
||||||
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
|
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
|
||||||
_checklistItems.update { items ->
|
_checklistItems.update { items ->
|
||||||
|
val fromItem = items.getOrNull(fromIndex) ?: return@update items
|
||||||
|
val toItem = items.getOrNull(toIndex) ?: return@update items
|
||||||
|
|
||||||
|
// 🆕 v1.8.0 (IMPL_017): Drag nur innerhalb der gleichen Gruppe erlauben
|
||||||
|
// (checked ↔ checked, unchecked ↔ unchecked)
|
||||||
|
if (fromItem.isChecked != toItem.isChecked) {
|
||||||
|
return@update items // Kein Move über Gruppen-Grenze
|
||||||
|
}
|
||||||
|
|
||||||
val mutableList = items.toMutableList()
|
val mutableList = items.toMutableList()
|
||||||
val item = mutableList.removeAt(fromIndex)
|
val item = mutableList.removeAt(fromIndex)
|
||||||
mutableList.add(toIndex, item)
|
mutableList.add(toIndex, item)
|
||||||
@@ -216,6 +252,37 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
|
||||||
|
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
|
||||||
|
*/
|
||||||
|
fun sortChecklistItems(option: ChecklistSortOption) {
|
||||||
|
// Merke die Auswahl für diesen Editor-Session
|
||||||
|
_lastChecklistSortOption.value = option
|
||||||
|
|
||||||
|
_checklistItems.update { items ->
|
||||||
|
val sorted = when (option) {
|
||||||
|
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
|
||||||
|
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
|
||||||
|
|
||||||
|
ChecklistSortOption.ALPHABETICAL_ASC ->
|
||||||
|
items.sortedBy { it.text.lowercase() }
|
||||||
|
|
||||||
|
ChecklistSortOption.ALPHABETICAL_DESC ->
|
||||||
|
items.sortedByDescending { it.text.lowercase() }
|
||||||
|
|
||||||
|
ChecklistSortOption.UNCHECKED_FIRST ->
|
||||||
|
items.sortedBy { it.isChecked }
|
||||||
|
|
||||||
|
ChecklistSortOption.CHECKED_FIRST ->
|
||||||
|
items.sortedByDescending { it.isChecked }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order-Werte neu zuweisen
|
||||||
|
sorted.mapIndexed { index, item -> item.copy(order = index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveNote() {
|
fun saveNote() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
@@ -231,6 +298,8 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
|
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
||||||
|
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
||||||
existingNote!!.copy(
|
existingNote!!.copy(
|
||||||
title = title,
|
title = title,
|
||||||
content = content,
|
content = content,
|
||||||
@@ -272,6 +341,8 @@ class NoteEditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
|
// 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt
|
||||||
|
// beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc.
|
||||||
existingNote!!.copy(
|
existingNote!!.copy(
|
||||||
title = title,
|
title = title,
|
||||||
content = "", // Empty for checklists
|
content = "", // Empty for checklists
|
||||||
@@ -300,6 +371,17 @@ class NoteEditorViewModel(
|
|||||||
// 🌟 v1.6.0: Trigger onSave Sync
|
// 🌟 v1.6.0: Trigger onSave Sync
|
||||||
triggerOnSaveSync()
|
triggerOnSaveSync()
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Betroffene Widgets aktualisieren
|
||||||
|
try {
|
||||||
|
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(getApplication())
|
||||||
|
val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java)
|
||||||
|
glanceIds.forEach { id ->
|
||||||
|
dev.dettmer.simplenotes.widget.NoteWidget().update(getApplication(), id)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to update widgets: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
_events.emit(NoteEditorEvent.NavigateBack)
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +430,41 @@ class NoteEditorViewModel(
|
|||||||
|
|
||||||
fun canDelete(): Boolean = existingNote != null
|
fun canDelete(): Boolean = existingNote != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0 (IMPL_025): Reload Note aus Storage nach Resume
|
||||||
|
*
|
||||||
|
* Wird aufgerufen wenn die Activity aus dem Hintergrund zurückkehrt.
|
||||||
|
* Liest den aktuellen Note-Stand von Disk und aktualisiert den ViewModel-State.
|
||||||
|
*
|
||||||
|
* Wird nur für existierende Checklist-Notes benötigt (neue Notes haben keinen
|
||||||
|
* externen Schreiber). Relevant für Widget-Checklist-Toggles.
|
||||||
|
*
|
||||||
|
* Nur checklistItems werden aktualisiert — nicht title oder content,
|
||||||
|
* damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen.
|
||||||
|
*/
|
||||||
|
fun reloadFromStorage() {
|
||||||
|
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID) ?: return
|
||||||
|
|
||||||
|
val freshNote = storage.loadNote(noteId) ?: return
|
||||||
|
|
||||||
|
// Nur Checklist-Items aktualisieren
|
||||||
|
if (freshNote.noteType == NoteType.CHECKLIST) {
|
||||||
|
val freshItems = freshNote.checklistItems?.sortedBy { it.order }?.map {
|
||||||
|
ChecklistItemState(
|
||||||
|
id = it.id,
|
||||||
|
text = it.text,
|
||||||
|
isChecked = it.isChecked,
|
||||||
|
order = it.order
|
||||||
|
)
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
_checklistItems.value = sortChecklistItems(freshItems)
|
||||||
|
// existingNote aktualisieren damit beim Speichern der richtige
|
||||||
|
// Basis-State verwendet wird
|
||||||
|
existingNote = freshNote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// 🌟 v1.6.0: Sync Trigger - onSave
|
// 🌟 v1.6.0: Sync Trigger - onSave
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items
|
||||||
|
*
|
||||||
|
* Zeigt eine dezente Linie mit Anzahl der erledigten Items:
|
||||||
|
* ── 3 completed ──
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CheckedItemsSeparator(
|
||||||
|
checkedCount: Int,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(
|
||||||
|
R.plurals.checked_items_count,
|
||||||
|
checkedCount,
|
||||||
|
checkedCount
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
@@ -21,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -30,13 +34,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
||||||
@@ -45,7 +53,13 @@ import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
|||||||
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
|
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
|
||||||
*
|
*
|
||||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
|
* v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus)
|
||||||
|
* v1.8.0: IMPL_023 - Enlarged drag handle (48dp touch target) + drag modifier
|
||||||
|
*
|
||||||
|
* Note: Using 10 parameters for Composable is acceptable for complex UI components.
|
||||||
|
* @suppress LongParameterList - Composables naturally have many parameters
|
||||||
*/
|
*/
|
||||||
|
@Suppress("LongParameterList")
|
||||||
@Composable
|
@Composable
|
||||||
fun ChecklistItemRow(
|
fun ChecklistItemRow(
|
||||||
item: ChecklistItemState,
|
item: ChecklistItemState,
|
||||||
@@ -54,12 +68,40 @@ fun ChecklistItemRow(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onAddNewItem: () -> Unit,
|
onAddNewItem: () -> Unit,
|
||||||
requestFocus: Boolean = false,
|
requestFocus: Boolean = false,
|
||||||
|
isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state
|
||||||
|
isAnyItemDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Hide gradient during any drag
|
||||||
|
dragModifier: Modifier = Modifier, // 🆕 v1.8.0: IMPL_023 - Drag modifier for handle
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val density = LocalDensity.current
|
||||||
var textFieldValue by remember(item.id) {
|
var textFieldValue by remember(item.id) {
|
||||||
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length)))
|
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Focus-State tracken für Expand/Collapse
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines)
|
||||||
|
var hasOverflow by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Höhe für collapsed-Ansicht (aus TextLayout berechnet)
|
||||||
|
var collapsedHeightDp by remember { mutableStateOf<Dp?>(null) }
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: ScrollState für dynamischen Gradient
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Scroll-basierter Ansatz aktiv wenn Höhe berechnet wurde
|
||||||
|
val useScrollClipping = hasOverflow && collapsedHeightDp != null
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Dynamische Gradient-Sichtbarkeit basierend auf Scroll-Position
|
||||||
|
val showGradient = useScrollClipping && !isFocused && !isAnyItemDragging
|
||||||
|
val showTopGradient by remember {
|
||||||
|
derivedStateOf { showGradient && scrollState.value > 0 }
|
||||||
|
}
|
||||||
|
val showBottomGradient by remember {
|
||||||
|
derivedStateOf { showGradient && scrollState.value < scrollState.maxValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
||||||
@@ -70,12 +112,21 @@ fun ChecklistItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Cursor ans Ende setzen wenn fokussiert (für Bearbeitung)
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused && textFieldValue.selection.start == 0) {
|
||||||
|
textFieldValue = textFieldValue.copy(
|
||||||
|
selection = TextRange(textFieldValue.text.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update text field when external state changes
|
// Update text field when external state changes
|
||||||
LaunchedEffect(item.text) {
|
LaunchedEffect(item.text) {
|
||||||
if (textFieldValue.text != item.text) {
|
if (textFieldValue.text != item.text) {
|
||||||
textFieldValue = TextFieldValue(
|
textFieldValue = TextFieldValue(
|
||||||
text = item.text,
|
text = item.text,
|
||||||
selection = TextRange(item.text.length)
|
selection = if (isFocused) TextRange(item.text.length) else TextRange(0)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,20 +138,27 @@ fun ChecklistItemRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch
|
||||||
) {
|
) {
|
||||||
// Drag Handle
|
// 🆕 v1.8.0: IMPL_023 - Vergrößerter Drag Handle (48dp Touch-Target)
|
||||||
Icon(
|
Box(
|
||||||
imageVector = Icons.Default.DragHandle,
|
modifier = dragModifier
|
||||||
contentDescription = stringResource(R.string.drag_to_reorder),
|
.size(48.dp) // Material Design minimum touch target
|
||||||
modifier = Modifier
|
.alpha(if (isDragging) 1.0f else 0.6f), // Visual feedback beim Drag
|
||||||
.size(24.dp)
|
contentAlignment = Alignment.Center
|
||||||
.alpha(0.5f),
|
) {
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
Icon(
|
||||||
)
|
imageVector = Icons.Default.DragHandle,
|
||||||
|
contentDescription = stringResource(R.string.drag_to_reorder),
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
modifier = Modifier.size(28.dp), // Icon größer als vorher (24dp → 28dp)
|
||||||
|
tint = if (isDragging) {
|
||||||
|
MaterialTheme.colorScheme.primary // Primary color während Drag
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Checkbox
|
// Checkbox
|
||||||
Checkbox(
|
Checkbox(
|
||||||
@@ -111,62 +169,111 @@ fun ChecklistItemRow(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
// Text Input with placeholder
|
// 🆕 v1.8.0: Text Input mit dynamischem Overflow-Gradient
|
||||||
BasicTextField(
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
value = textFieldValue,
|
// Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
|
||||||
onValueChange = { newValue ->
|
Box(
|
||||||
// Check for newline (Enter key)
|
modifier = if (!isFocused && useScrollClipping) {
|
||||||
if (newValue.text.contains("\n")) {
|
Modifier
|
||||||
val cleanText = newValue.text.replace("\n", "")
|
.heightIn(max = collapsedHeightDp!!)
|
||||||
textFieldValue = TextFieldValue(
|
.verticalScroll(scrollState)
|
||||||
text = cleanText,
|
|
||||||
selection = TextRange(cleanText.length)
|
|
||||||
)
|
|
||||||
onTextChange(cleanText)
|
|
||||||
onAddNewItem()
|
|
||||||
} else {
|
} else {
|
||||||
textFieldValue = newValue
|
Modifier
|
||||||
onTextChange(newValue.text)
|
|
||||||
}
|
}
|
||||||
},
|
) {
|
||||||
modifier = Modifier
|
BasicTextField(
|
||||||
.weight(1f)
|
value = textFieldValue,
|
||||||
.focusRequester(focusRequester)
|
onValueChange = { newValue ->
|
||||||
.alpha(alpha),
|
// Check for newline (Enter key)
|
||||||
textStyle = LocalTextStyle.current.copy(
|
if (newValue.text.contains("\n")) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
val cleanText = newValue.text.replace("\n", "")
|
||||||
textDecoration = textDecoration
|
textFieldValue = TextFieldValue(
|
||||||
),
|
text = cleanText,
|
||||||
keyboardOptions = KeyboardOptions(
|
selection = TextRange(cleanText.length)
|
||||||
imeAction = ImeAction.Next
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { onAddNewItem() }
|
|
||||||
),
|
|
||||||
singleLine = false,
|
|
||||||
maxLines = 5,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (textFieldValue.text.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.item_placeholder),
|
|
||||||
style = LocalTextStyle.current.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
|
||||||
)
|
)
|
||||||
)
|
onTextChange(cleanText)
|
||||||
|
onAddNewItem()
|
||||||
|
} else {
|
||||||
|
textFieldValue = newValue
|
||||||
|
onTextChange(newValue.text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
isFocused = focusState.isFocused
|
||||||
|
}
|
||||||
|
.alpha(alpha),
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textDecoration = textDecoration
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { onAddNewItem() }
|
||||||
|
),
|
||||||
|
singleLine = false,
|
||||||
|
// maxLines nur als Fallback bis collapsedHeight berechnet ist
|
||||||
|
maxLines = if (isFocused || useScrollClipping) Int.MAX_VALUE else COLLAPSED_MAX_LINES,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
onTextLayout = { textLayoutResult ->
|
||||||
|
// 🆕 v1.8.0: Overflow erkennen - ABER NUR wenn kein Drag aktiv ist
|
||||||
|
if (!isAnyItemDragging) {
|
||||||
|
val overflow = textLayoutResult.lineCount > COLLAPSED_MAX_LINES
|
||||||
|
hasOverflow = overflow
|
||||||
|
// Höhe der ersten 5 Zeilen berechnen (einmalig)
|
||||||
|
if (overflow && collapsedHeightDp == null) {
|
||||||
|
collapsedHeightDp = with(density) {
|
||||||
|
textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (textFieldValue.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.item_placeholder),
|
||||||
|
style = LocalTextStyle.current.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
innerTextField()
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
// 🆕 v1.8.0: Dynamischer Gradient basierend auf Scroll-Position
|
||||||
|
// Oben: sichtbar wenn nach unten gescrollt (Text oberhalb versteckt)
|
||||||
|
if (showTopGradient) {
|
||||||
|
OverflowGradient(
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
isTopGradient = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unten: sichtbar wenn noch Text unterhalb vorhanden
|
||||||
|
if (showBottomGradient) {
|
||||||
|
OverflowGradient(
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
isTopGradient = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
// Delete Button
|
// Delete Button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDelete,
|
onClick = onDelete,
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(top = 4.dp) // 🆕 v1.8.0: Ausrichtung mit Top-aligned Text
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
@@ -177,3 +284,92 @@ fun ChecklistItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Maximum lines when collapsed (not focused)
|
||||||
|
private const val COLLAPSED_MAX_LINES = 5
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.8.0: Preview Composables for Manual Testing
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Suppress("UnusedPrivateMember")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowShortTextPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-1",
|
||||||
|
text = "Kurzer Text",
|
||||||
|
isChecked = false
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {},
|
||||||
|
isDragging = false,
|
||||||
|
dragModifier = Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnusedPrivateMember")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowLongTextPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-2",
|
||||||
|
text = "Dies ist ein sehr langer Text der sich über viele Zeilen erstreckt " +
|
||||||
|
"und dazu dient den Overflow-Gradient zu demonstrieren. Er hat deutlich " +
|
||||||
|
"mehr als fünf Zeilen wenn er in der normalen Breite eines Smartphones " +
|
||||||
|
"angezeigt wird und sollte einen schönen Fade-Effekt am unteren Rand zeigen. " +
|
||||||
|
"Dieser zusätzliche Text sorgt dafür, dass wir wirklich genug Zeilen haben " +
|
||||||
|
"um den Gradient sichtbar zu machen.",
|
||||||
|
isChecked = false
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {},
|
||||||
|
isDragging = false,
|
||||||
|
dragModifier = Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnusedPrivateMember")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowCheckedPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-3",
|
||||||
|
text = "Erledigte Aufgabe mit durchgestrichenem Text",
|
||||||
|
isChecked = true
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {},
|
||||||
|
isDragging = false,
|
||||||
|
dragModifier = Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: IMPL_023 - Preview for dragging state
|
||||||
|
@Suppress("UnusedPrivateMember")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowDraggingPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-4",
|
||||||
|
text = "Wird gerade verschoben - Handle ist highlighted",
|
||||||
|
isChecked = false
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {},
|
||||||
|
isDragging = true,
|
||||||
|
dragModifier = Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Dialog zur Auswahl der Checklist-Sortierung.
|
||||||
|
*
|
||||||
|
* Einmalige Sortier-Aktion (nicht persistiert).
|
||||||
|
* User kann danach per Drag & Drop feinjustieren.
|
||||||
|
*
|
||||||
|
* ┌─────────────────────────────────┐
|
||||||
|
* │ Sort Checklist │
|
||||||
|
* ├─────────────────────────────────┤
|
||||||
|
* │ ( ) Manual │
|
||||||
|
* │ ( ) A → Z │
|
||||||
|
* │ ( ) Z → A │
|
||||||
|
* │ (●) Unchecked first │
|
||||||
|
* │ ( ) Checked first │
|
||||||
|
* ├─────────────────────────────────┤
|
||||||
|
* │ [Cancel] [Apply] │
|
||||||
|
* └─────────────────────────────────┘
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChecklistSortDialog(
|
||||||
|
currentOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Auswahl merken
|
||||||
|
onOptionSelected: (ChecklistSortOption) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedOption by remember { mutableStateOf(currentOption) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sort_checklist),
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
ChecklistSortOption.entries.forEach { option ->
|
||||||
|
SortOptionRow(
|
||||||
|
label = stringResource(option.toStringRes()),
|
||||||
|
isSelected = selectedOption == option,
|
||||||
|
onClick = { selectedOption = option }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onOptionSelected(selectedOption)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.apply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SortOptionRow(
|
||||||
|
label: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension: ChecklistSortOption → String-Resource-ID
|
||||||
|
*/
|
||||||
|
fun ChecklistSortOption.toStringRes(): Int = when (this) {
|
||||||
|
ChecklistSortOption.MANUAL -> R.string.sort_checklist_manual
|
||||||
|
ChecklistSortOption.ALPHABETICAL_ASC -> R.string.sort_checklist_alpha_asc
|
||||||
|
ChecklistSortOption.ALPHABETICAL_DESC -> R.string.sort_checklist_alpha_desc
|
||||||
|
ChecklistSortOption.UNCHECKED_FIRST -> R.string.sort_checklist_unchecked_first
|
||||||
|
ChecklistSortOption.CHECKED_FIRST -> R.string.sort_checklist_checked_first
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Dezenter Gradient-Overlay der anzeigt, dass mehr Text
|
||||||
|
* vorhanden ist als aktuell sichtbar.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Top gradient: surface → transparent (zeigt Text oberhalb)
|
||||||
|
* - Bottom gradient: transparent → surface (zeigt Text unterhalb)
|
||||||
|
* - Höhe: 24dp für subtilen, aber erkennbaren Effekt
|
||||||
|
* - Material You kompatibel: nutzt dynamische surface-Farbe
|
||||||
|
* - Dark Mode Support: automatisch durch MaterialTheme
|
||||||
|
*
|
||||||
|
* Verwendet in: ChecklistItemRow für lange Texteinträge
|
||||||
|
*
|
||||||
|
* @param isTopGradient true = Gradient von surface→transparent (oben), false = transparent→surface (unten)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OverflowGradient(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isTopGradient: Boolean = false
|
||||||
|
) {
|
||||||
|
val surfaceColor = MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
val gradientColors = if (isTopGradient) {
|
||||||
|
// Oben: surface → transparent (zeigt dass Text OBERHALB existiert)
|
||||||
|
listOf(
|
||||||
|
surfaceColor.copy(alpha = 0.95f),
|
||||||
|
surfaceColor.copy(alpha = 0.7f),
|
||||||
|
Color.Transparent
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Unten: transparent → surface (zeigt dass Text UNTERHALB existiert)
|
||||||
|
listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
surfaceColor.copy(alpha = 0.7f),
|
||||||
|
surfaceColor.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(GRADIENT_HEIGHT)
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(colors = gradientColors)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val GRADIENT_HEIGHT = 24.dp
|
||||||
@@ -170,6 +170,9 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
onOpenSettings = { openSettings() },
|
onOpenSettings = { openSettings() },
|
||||||
onCreateNote = { noteType -> createNote(noteType) }
|
onCreateNote = { noteType -> createNote(noteType) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// v1.8.0: Post-Update Changelog (shows once after update)
|
||||||
|
UpdateChangelogSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,25 +222,26 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSyncStateObserver() {
|
private fun setupSyncStateObserver() {
|
||||||
|
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
|
||||||
SyncStateManager.syncStatus.observe(this) { status ->
|
SyncStateManager.syncStatus.observe(this) { status ->
|
||||||
viewModel.updateSyncState(status)
|
viewModel.updateSyncState(status)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("MagicNumber") // UI timing delays for banner visibility
|
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
|
||||||
// Hide banner after delay for completed/error states
|
lifecycleScope.launch {
|
||||||
when (status.state) {
|
SyncStateManager.syncProgress.collect { progress ->
|
||||||
SyncStateManager.SyncState.COMPLETED -> {
|
@Suppress("MagicNumber") // UI timing delays for banner visibility
|
||||||
lifecycleScope.launch {
|
when (progress.phase) {
|
||||||
kotlinx.coroutines.delay(1500L)
|
dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> {
|
||||||
|
kotlinx.coroutines.delay(2000L)
|
||||||
SyncStateManager.reset()
|
SyncStateManager.reset()
|
||||||
}
|
}
|
||||||
}
|
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
|
||||||
SyncStateManager.SyncState.ERROR -> {
|
kotlinx.coroutines.delay(4000L)
|
||||||
lifecycleScope.launch {
|
|
||||||
kotlinx.coroutines.delay(3000L)
|
|
||||||
SyncStateManager.reset()
|
SyncStateManager.reset()
|
||||||
}
|
}
|
||||||
|
else -> { /* No action needed */ }
|
||||||
}
|
}
|
||||||
else -> { /* No action needed */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutVertically
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||||
@@ -17,6 +18,8 @@ import androidx.compose.material.icons.filled.Delete
|
|||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Sort
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
// FabPosition nicht mehr benötigt - FAB wird manuell platziert
|
// FabPosition nicht mehr benötigt - FAB wird manuell platziert
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -46,15 +49,19 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.NoteType
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.SortDialog
|
||||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||||
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
||||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
|
||||||
|
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main screen displaying the notes list
|
* Main screen displaying the notes list
|
||||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
@@ -72,11 +79,13 @@ fun MainScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onCreateNote: (NoteType) -> Unit
|
onCreateNote: (NoteType) -> Unit
|
||||||
) {
|
) {
|
||||||
val notes by viewModel.notes.collectAsState()
|
val notes by viewModel.sortedNotes.collectAsState()
|
||||||
val syncState by viewModel.syncState.collectAsState()
|
val syncState by viewModel.syncState.collectAsState()
|
||||||
val syncMessage by viewModel.syncMessage.collectAsState()
|
|
||||||
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Einziges Banner-System
|
||||||
|
val syncProgress by viewModel.syncProgress.collectAsState()
|
||||||
|
|
||||||
// Multi-Select State
|
// Multi-Select State
|
||||||
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||||
@@ -90,12 +99,29 @@ fun MainScreen(
|
|||||||
// Delete confirmation dialog state
|
// Delete confirmation dialog state
|
||||||
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Sync status legend dialog
|
||||||
|
var showSyncLegend by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🔀 v1.8.0: Sort dialog state
|
||||||
|
var showSortDialog by remember { mutableStateOf(false) }
|
||||||
|
val sortOption by viewModel.sortOption.collectAsState()
|
||||||
|
val sortDirection by viewModel.sortDirection.collectAsState()
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||||
val gridState = rememberLazyStaggeredGridState()
|
val gridState = rememberLazyStaggeredGridState()
|
||||||
|
|
||||||
|
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
|
||||||
|
var timestampTicker by remember { mutableStateOf(0L) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(TIMESTAMP_UPDATE_INTERVAL_MS)
|
||||||
|
timestampTicker = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute isSyncing once
|
// Compute isSyncing once
|
||||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||||
|
|
||||||
@@ -159,6 +185,9 @@ fun MainScreen(
|
|||||||
) {
|
) {
|
||||||
MainTopBar(
|
MainTopBar(
|
||||||
syncEnabled = canSync,
|
syncEnabled = canSync,
|
||||||
|
showSyncLegend = isSyncAvailable, // 🆕 v1.8.0: Nur wenn Sync verfügbar
|
||||||
|
onSyncLegendClick = { showSyncLegend = true }, // 🆕 v1.8.0
|
||||||
|
onSortClick = { showSortDialog = true }, // 🔀 v1.8.0
|
||||||
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
||||||
onSettingsClick = onOpenSettings
|
onSettingsClick = onOpenSettings
|
||||||
)
|
)
|
||||||
@@ -179,10 +208,10 @@ fun MainScreen(
|
|||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Main content column
|
// Main content column
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Sync Status Banner (not affected by pull-to-refresh)
|
// 🆕 v1.8.0: Einziges Sync Banner (Progress + Ergebnis)
|
||||||
SyncStatusBanner(
|
SyncProgressBanner(
|
||||||
syncState = syncState,
|
progress = syncProgress,
|
||||||
message = syncMessage
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content: Empty state or notes list
|
// Content: Empty state or notes list
|
||||||
@@ -197,6 +226,7 @@ fun MainScreen(
|
|||||||
showSyncStatus = viewModel.isServerConfigured(),
|
showSyncStatus = viewModel.isServerConfigured(),
|
||||||
selectedNoteIds = selectedNotes,
|
selectedNoteIds = selectedNotes,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onNoteClick = { note ->
|
onNoteClick = { note ->
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
@@ -215,6 +245,7 @@ fun MainScreen(
|
|||||||
showSyncStatus = viewModel.isServerConfigured(),
|
showSyncStatus = viewModel.isServerConfigured(),
|
||||||
selectedNotes = selectedNotes,
|
selectedNotes = selectedNotes,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onNoteClick = { note -> onOpenNote(note.id) },
|
onNoteClick = { note -> onOpenNote(note.id) },
|
||||||
@@ -263,6 +294,28 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Sync Status Legend Dialog
|
||||||
|
if (showSyncLegend) {
|
||||||
|
SyncStatusLegendDialog(
|
||||||
|
onDismiss = { showSyncLegend = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔀 v1.8.0: Sort Dialog
|
||||||
|
if (showSortDialog) {
|
||||||
|
SortDialog(
|
||||||
|
currentOption = sortOption,
|
||||||
|
currentDirection = sortDirection,
|
||||||
|
onOptionSelected = { option ->
|
||||||
|
viewModel.setSortOption(option)
|
||||||
|
},
|
||||||
|
onDirectionToggled = {
|
||||||
|
viewModel.toggleSortDirection()
|
||||||
|
},
|
||||||
|
onDismiss = { showSortDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +323,9 @@ fun MainScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MainTopBar(
|
private fun MainTopBar(
|
||||||
syncEnabled: Boolean,
|
syncEnabled: Boolean,
|
||||||
|
showSyncLegend: Boolean, // 🆕 v1.8.0: Ob der Hilfe-Button sichtbar sein soll
|
||||||
|
onSyncLegendClick: () -> Unit, // 🆕 v1.8.0
|
||||||
|
onSortClick: () -> Unit, // 🔀 v1.8.0: Sort-Button
|
||||||
onSyncClick: () -> Unit,
|
onSyncClick: () -> Unit,
|
||||||
onSettingsClick: () -> Unit
|
onSettingsClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -281,6 +337,23 @@ private fun MainTopBar(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
// 🔀 v1.8.0: Sort Button
|
||||||
|
IconButton(onClick = onSortClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.Sort,
|
||||||
|
contentDescription = stringResource(R.string.sort_notes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
|
||||||
|
if (showSyncLegend) {
|
||||||
|
IconButton(onClick = onSyncLegendClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
|
||||||
|
contentDescription = stringResource(R.string.sync_legend_button)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSyncClick,
|
onClick = onSyncClick,
|
||||||
enabled = syncEnabled
|
enabled = syncEnabled
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import android.content.Context
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.SortDirection
|
||||||
|
import dev.dettmer.simplenotes.models.SortOption
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncProgress
|
||||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
@@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -102,15 +106,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Sync State (derived from SyncStateManager)
|
// 🔀 v1.8.0: Sort State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private val _sortOption = MutableStateFlow(
|
||||||
|
SortOption.fromPrefsValue(
|
||||||
|
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
|
||||||
|
|
||||||
|
private val _sortDirection = MutableStateFlow(
|
||||||
|
SortDirection.fromPrefsValue(
|
||||||
|
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
|
||||||
|
* Reagiert automatisch auf Änderungen in allen drei Flows.
|
||||||
|
*/
|
||||||
|
val sortedNotes: StateFlow<List<Note>> = combine(
|
||||||
|
_notes,
|
||||||
|
_sortOption,
|
||||||
|
_sortDirection
|
||||||
|
) { notes, option, direction ->
|
||||||
|
sortNotes(notes, option, direction)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Sync State
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
|
||||||
|
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
|
||||||
|
|
||||||
|
// Intern: SyncState für PullToRefresh-Indikator
|
||||||
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
|
||||||
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
||||||
|
|
||||||
private val _syncMessage = MutableStateFlow<String?>(null)
|
|
||||||
val syncMessage: StateFlow<String?> = _syncMessage.asStateFlow()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// UI Events
|
// UI Events
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -495,12 +534,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
||||||
_syncState.value = status.state
|
_syncState.value = status.state
|
||||||
_syncMessage.value = status.message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||||
|
* v1.8.0: Banner erscheint sofort beim Klick (PREPARING-Phase)
|
||||||
*/
|
*/
|
||||||
fun triggerManualSync(source: String = "manual") {
|
fun triggerManualSync(source: String = "manual") {
|
||||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
@@ -509,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (!gateResult.canSync) {
|
if (!gateResult.canSync) {
|
||||||
if (gateResult.isBlockedByWifiOnly) {
|
if (gateResult.isBlockedByWifiOnly) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
|
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
|
||||||
SyncStateManager.markError(getString(R.string.sync_wifi_only_hint))
|
SyncStateManager.markError(getString(R.string.sync_wifi_only_error))
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||||
}
|
}
|
||||||
@@ -517,6 +556,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||||
|
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
|
||||||
if (!SyncStateManager.tryStartSync(source)) {
|
if (!SyncStateManager.tryStartSync(source)) {
|
||||||
if (SyncStateManager.isSyncing) {
|
if (SyncStateManager.isSyncing) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
|
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
|
||||||
@@ -533,11 +573,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// Check for unsynced changes
|
// Check for unsynced changes (Banner zeigt bereits PREPARING)
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
||||||
val message = getApplication<Application>().getString(R.string.toast_already_synced)
|
SyncStateManager.markCompleted(getString(R.string.toast_already_synced))
|
||||||
SyncStateManager.markCompleted(message)
|
|
||||||
loadNotes()
|
loadNotes()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -559,10 +598,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
val bannerMessage = if (result.syncedCount > 0) {
|
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
|
||||||
getString(R.string.toast_sync_success, result.syncedCount)
|
val bannerMessage = buildString {
|
||||||
} else {
|
if (result.syncedCount > 0) {
|
||||||
getString(R.string.snackbar_nothing_to_sync)
|
append(getString(R.string.toast_sync_success, result.syncedCount))
|
||||||
|
}
|
||||||
|
if (result.deletedOnServerCount > 0) {
|
||||||
|
if (isNotEmpty()) append(" · ")
|
||||||
|
append(getString(R.string.sync_deleted_on_server_count, result.deletedOnServerCount))
|
||||||
|
}
|
||||||
|
if (isEmpty()) {
|
||||||
|
append(getString(R.string.snackbar_nothing_to_sync))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SyncStateManager.markCompleted(bannerMessage)
|
SyncStateManager.markCompleted(bannerMessage)
|
||||||
loadNotes()
|
loadNotes()
|
||||||
@@ -606,7 +653,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.5.0: silent=true - kein Banner bei Auto-Sync, aber Fehler werden trotzdem angezeigt
|
// v1.5.0: silent=true → kein Banner bei Auto-Sync
|
||||||
|
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
|
||||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||||
return
|
return
|
||||||
@@ -622,7 +670,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||||
SyncStateManager.reset()
|
SyncStateManager.reset() // Silent → geht direkt auf IDLE
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +681,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
if (!isReachable) {
|
if (!isReachable) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||||
SyncStateManager.reset()
|
SyncStateManager.reset() // Silent → kein Error-Banner
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,14 +692,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
if (result.isSuccess && result.syncedCount > 0) {
|
if (result.isSuccess && result.syncedCount > 0) {
|
||||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||||
|
// Silent Sync mit echten Änderungen → trotzdem markCompleted (wird silent behandelt)
|
||||||
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
|
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
|
||||||
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
|
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||||
loadNotes()
|
loadNotes()
|
||||||
} else if (result.isSuccess) {
|
} else if (result.isSuccess) {
|
||||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||||
SyncStateManager.markCompleted(getString(R.string.snackbar_nothing_to_sync))
|
SyncStateManager.markCompleted() // Silent → geht direkt auf IDLE
|
||||||
} else {
|
} else {
|
||||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||||
|
// Fehler werden IMMER angezeigt (auch bei Silent-Sync)
|
||||||
SyncStateManager.markError(result.errorMessage)
|
SyncStateManager.markError(result.errorMessage)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -675,6 +725,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🔀 v1.8.0: Sortierung
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
|
||||||
|
*/
|
||||||
|
private fun sortNotes(
|
||||||
|
notes: List<Note>,
|
||||||
|
option: SortOption,
|
||||||
|
direction: SortDirection
|
||||||
|
): List<Note> {
|
||||||
|
val comparator: Comparator<Note> = when (option) {
|
||||||
|
SortOption.UPDATED_AT -> compareBy { it.updatedAt }
|
||||||
|
SortOption.CREATED_AT -> compareBy { it.createdAt }
|
||||||
|
SortOption.TITLE -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }
|
||||||
|
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
|
||||||
|
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (direction) {
|
||||||
|
SortDirection.ASCENDING -> notes.sortedWith(comparator)
|
||||||
|
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
|
||||||
|
*/
|
||||||
|
fun setSortOption(option: SortOption) {
|
||||||
|
_sortOption.value = option
|
||||||
|
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
|
||||||
|
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
|
||||||
|
*/
|
||||||
|
fun setSortDirection(direction: SortDirection) {
|
||||||
|
_sortDirection.value = direction
|
||||||
|
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
|
||||||
|
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
|
||||||
|
*/
|
||||||
|
fun toggleSortDirection() {
|
||||||
|
val newDirection = _sortDirection.value.toggle()
|
||||||
|
setSortDirection(newDirection)
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Helpers
|
// Helpers
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withLink
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.8.0: Post-Update Changelog Bottom Sheet
|
||||||
|
*
|
||||||
|
* Shows a subtle changelog on first launch after an update.
|
||||||
|
* - Reads changelog from raw resources (supports DE/EN)
|
||||||
|
* - Only shows once per versionCode (stored in SharedPreferences)
|
||||||
|
* - Uses Material 3 ModalBottomSheet with built-in slide-up animation
|
||||||
|
* - Dismissable via button or swipe-down
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun UpdateChangelogSheet() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val prefs = remember {
|
||||||
|
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersionCode = BuildConfig.VERSION_CODE
|
||||||
|
val lastShownVersion = prefs.getInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
|
||||||
|
|
||||||
|
// Only show if this is a new version
|
||||||
|
var showSheet by remember { mutableStateOf(currentVersionCode > lastShownVersion) }
|
||||||
|
|
||||||
|
if (!showSheet) return
|
||||||
|
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Load changelog text based on current locale
|
||||||
|
val changelogText = remember {
|
||||||
|
loadChangelog(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = {
|
||||||
|
showSheet = false
|
||||||
|
prefs.edit()
|
||||||
|
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode)
|
||||||
|
.apply()
|
||||||
|
},
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_changelog_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Changelog content with clickable links
|
||||||
|
val annotatedText = buildAnnotatedString {
|
||||||
|
val lines = changelogText.split("\n")
|
||||||
|
lines.forEachIndexed { index, line ->
|
||||||
|
if (line.startsWith("http://") || line.startsWith("https://")) {
|
||||||
|
// Make URLs clickable
|
||||||
|
withLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url = line.trim(),
|
||||||
|
styles = androidx.compose.ui.text.TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
append(line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
append(line)
|
||||||
|
}
|
||||||
|
if (index < lines.size - 1) append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = annotatedText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Dismiss button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
sheetState.hide()
|
||||||
|
}.invokeOnCompletion {
|
||||||
|
showSheet = false
|
||||||
|
prefs.edit()
|
||||||
|
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.update_changelog_dismiss))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load changelog text from assets based on current app locale and versionCode.
|
||||||
|
* Changelogs are copied from /fastlane/metadata/android/{locale}/changelogs/{versionCode}.txt
|
||||||
|
* at build time, providing a single source of truth for F-Droid and in-app display.
|
||||||
|
* Falls back to English if the localized version is not available.
|
||||||
|
*/
|
||||||
|
private fun loadChangelog(context: Context): String {
|
||||||
|
val currentLocale = AppCompatDelegate.getApplicationLocales()
|
||||||
|
val languageCode = if (currentLocale.isEmpty) {
|
||||||
|
// System default — check system locale
|
||||||
|
java.util.Locale.getDefault().language
|
||||||
|
} else {
|
||||||
|
currentLocale.get(0)?.language ?: "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map language code to F-Droid locale directory
|
||||||
|
val localeDir = when (languageCode) {
|
||||||
|
"de" -> "de-DE"
|
||||||
|
else -> "en-US"
|
||||||
|
}
|
||||||
|
|
||||||
|
val versionCode = BuildConfig.VERSION_CODE
|
||||||
|
val changelogPath = "changelogs/$localeDir/$versionCode.txt"
|
||||||
|
|
||||||
|
return try {
|
||||||
|
context.assets.open(changelogPath)
|
||||||
|
.bufferedReader()
|
||||||
|
.use { it.readText() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e("UpdateChangelogSheet", "Failed to load changelog for locale: $localeDir", e)
|
||||||
|
// Fallback to English
|
||||||
|
try {
|
||||||
|
context.assets.open("changelogs/en-US/$versionCode.txt")
|
||||||
|
.bufferedReader()
|
||||||
|
.use { it.readText() }
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Logger.e("UpdateChangelogSheet", "Failed to load English fallback changelog", e2)
|
||||||
|
"v${BuildConfig.VERSION_NAME}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,12 +63,17 @@ fun NoteCard(
|
|||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val ticker = timestampTicker
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -181,11 +186,19 @@ fun NoteCard(
|
|||||||
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
||||||
SyncStatus.CONFLICT -> Icons.Default.Warning
|
SyncStatus.CONFLICT -> Icons.Default.Warning
|
||||||
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
|
||||||
|
},
|
||||||
|
contentDescription = when (note.syncStatus) {
|
||||||
|
SyncStatus.SYNCED -> stringResource(R.string.sync_status_synced)
|
||||||
|
SyncStatus.PENDING -> stringResource(R.string.sync_status_pending)
|
||||||
|
SyncStatus.CONFLICT -> stringResource(R.string.sync_status_conflict)
|
||||||
|
SyncStatus.LOCAL_ONLY -> stringResource(R.string.sync_status_local_only)
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> stringResource(R.string.sync_status_deleted_on_server) // 🆕 v1.8.0
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
|
||||||
tint = when (note.syncStatus) {
|
tint = when (note.syncStatus) {
|
||||||
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||||
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
|
||||||
else -> MaterialTheme.colorScheme.outline
|
else -> MaterialTheme.colorScheme.outline
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
|
|||||||
@@ -187,11 +187,13 @@ fun NoteCardCompact(
|
|||||||
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
||||||
SyncStatus.CONFLICT -> Icons.Default.Warning
|
SyncStatus.CONFLICT -> Icons.Default.Warning
|
||||||
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = when (note.syncStatus) {
|
tint = when (note.syncStatus) {
|
||||||
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||||
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
|
||||||
else -> MaterialTheme.colorScheme.outline
|
else -> MaterialTheme.colorScheme.outline
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(14.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -65,11 +66,18 @@ fun NoteCardGrid(
|
|||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val noteSize = note.getSize()
|
|
||||||
|
// 🚀 Performance: Cache noteSize - nur bei note-Änderung neu berechnen
|
||||||
|
val noteSize = remember(note.id, note.content, note.checklistItems) { note.getSize() }
|
||||||
|
|
||||||
|
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val ticker = timestampTicker
|
||||||
|
|
||||||
// Dynamische maxLines basierend auf Größe
|
// Dynamische maxLines basierend auf Größe
|
||||||
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
|
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
|
||||||
@@ -191,11 +199,13 @@ fun NoteCardGrid(
|
|||||||
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
||||||
SyncStatus.CONFLICT -> Icons.Default.Warning
|
SyncStatus.CONFLICT -> Icons.Default.Warning
|
||||||
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = when (note.syncStatus) {
|
tint = when (note.syncStatus) {
|
||||||
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||||
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||||
|
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
|
||||||
else -> MaterialTheme.colorScheme.outline
|
else -> MaterialTheme.colorScheme.outline
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(14.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ import dev.dettmer.simplenotes.models.Note
|
|||||||
* - NO caching tricks
|
* - NO caching tricks
|
||||||
* - Selection state passed through as parameters
|
* - Selection state passed through as parameters
|
||||||
* - Tap behavior changes based on selection mode
|
* - Tap behavior changes based on selection mode
|
||||||
|
* - ⏱️ timestampTicker triggers recomposition for relative time updates
|
||||||
*/
|
*/
|
||||||
|
@Suppress("LongParameterList") // Composable with many UI state parameters
|
||||||
@Composable
|
@Composable
|
||||||
fun NotesList(
|
fun NotesList(
|
||||||
notes: List<Note>,
|
notes: List<Note>,
|
||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
selectedNotes: Set<String> = emptySet(),
|
selectedNotes: Set<String> = emptySet(),
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
onNoteClick: (Note) -> Unit,
|
onNoteClick: (Note) -> Unit,
|
||||||
@@ -50,6 +53,7 @@ fun NotesList(
|
|||||||
showSyncStatus = showSyncStatus,
|
showSyncStatus = showSyncStatus,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import dev.dettmer.simplenotes.utils.Constants
|
|||||||
* - Keine Lücken mehr durch FullLine-Items
|
* - Keine Lücken mehr durch FullLine-Items
|
||||||
* - Selection mode support
|
* - Selection mode support
|
||||||
* - Efficient LazyVerticalStaggeredGrid
|
* - Efficient LazyVerticalStaggeredGrid
|
||||||
|
* - ⏱️ timestampTicker triggers recomposition for relative time updates
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NotesStaggeredGrid(
|
fun NotesStaggeredGrid(
|
||||||
@@ -30,11 +31,11 @@ fun NotesStaggeredGrid(
|
|||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
selectedNoteIds: Set<String>,
|
selectedNoteIds: Set<String>,
|
||||||
isSelectionMode: Boolean,
|
isSelectionMode: Boolean,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onNoteClick: (Note) -> Unit,
|
onNoteClick: (Note) -> Unit,
|
||||||
onNoteLongClick: (Note) -> Unit
|
onNoteLongClick: (Note) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
|
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
@@ -51,7 +52,8 @@ fun NotesStaggeredGrid(
|
|||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = notes,
|
items = notes,
|
||||||
key = { it.id }
|
key = { it.id },
|
||||||
|
contentType = { "NoteCardGrid" }
|
||||||
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
|
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
|
||||||
) { note ->
|
) { note ->
|
||||||
val isSelected = selectedNoteIds.contains(note.id)
|
val isSelected = selectedNoteIds.contains(note.id)
|
||||||
@@ -62,6 +64,7 @@ fun NotesStaggeredGrid(
|
|||||||
showSyncStatus = showSyncStatus,
|
showSyncStatus = showSyncStatus,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
onClick = { onNoteClick(note) },
|
onClick = { onNoteClick(note) },
|
||||||
onLongClick = { onNoteLongClick(note) }
|
onLongClick = { onNoteLongClick(note) }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.SortDirection
|
||||||
|
import dev.dettmer.simplenotes.models.SortOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔀 v1.8.0: Dialog zur Auswahl der Sortierung für die Notizliste.
|
||||||
|
*
|
||||||
|
* Zeigt RadioButtons für die Sortieroption und einen Toggle für die Richtung.
|
||||||
|
*
|
||||||
|
* ┌─────────────────────────────────┐
|
||||||
|
* │ Sort Notes │
|
||||||
|
* ├─────────────────────────────────┤
|
||||||
|
* │ (●) Last modified ↓↑ │
|
||||||
|
* │ ( ) Date created │
|
||||||
|
* │ ( ) Name │
|
||||||
|
* │ ( ) Type │
|
||||||
|
* ├─────────────────────────────────┤
|
||||||
|
* │ [Close] │
|
||||||
|
* └─────────────────────────────────┘
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SortDialog(
|
||||||
|
currentOption: SortOption,
|
||||||
|
currentDirection: SortDirection,
|
||||||
|
onOptionSelected: (SortOption) -> Unit,
|
||||||
|
onDirectionToggled: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sort_notes),
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction Toggle Button
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDirectionToggled) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (currentDirection == SortDirection.DESCENDING) {
|
||||||
|
Icons.Default.ArrowDownward
|
||||||
|
} else {
|
||||||
|
Icons.Default.ArrowUpward
|
||||||
|
},
|
||||||
|
contentDescription = stringResource(
|
||||||
|
if (currentDirection == SortDirection.DESCENDING) {
|
||||||
|
R.string.sort_descending
|
||||||
|
} else {
|
||||||
|
R.string.sort_ascending
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (currentDirection == SortDirection.DESCENDING) {
|
||||||
|
R.string.sort_descending
|
||||||
|
} else {
|
||||||
|
R.string.sort_ascending
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
SortOption.entries.forEach { option ->
|
||||||
|
SortOptionRow(
|
||||||
|
label = stringResource(option.toStringRes()),
|
||||||
|
isSelected = currentOption == option,
|
||||||
|
onClick = { onOptionSelected(option) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SortOptionRow(
|
||||||
|
label: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension: SortOption → String-Resource-ID
|
||||||
|
*/
|
||||||
|
fun SortOption.toStringRes(): Int = when (this) {
|
||||||
|
SortOption.UPDATED_AT -> R.string.sort_by_updated
|
||||||
|
SortOption.CREATED_AT -> R.string.sort_by_created
|
||||||
|
SortOption.TITLE -> R.string.sort_by_name
|
||||||
|
SortOption.NOTE_TYPE -> R.string.sort_by_type
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.ErrorOutline
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncPhase
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncProgress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Einziges Sync-Banner für den gesamten Sync-Lebenszyklus
|
||||||
|
*
|
||||||
|
* Deckt alle Phasen ab:
|
||||||
|
* - PREPARING: Indeterminate Spinner + "Synchronisiere…" (sofort beim Klick, bleibt bis echte Arbeit)
|
||||||
|
* - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen
|
||||||
|
* - COMPLETED: Erfolgsmeldung mit Checkmark-Icon (auto-hide durch ComposeMainActivity)
|
||||||
|
* - ERROR: Fehlermeldung mit Error-Icon (auto-hide durch ComposeMainActivity)
|
||||||
|
*
|
||||||
|
* Silent Syncs (onResume) zeigen kein Banner (progress.isVisible == false)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SyncProgressBanner(
|
||||||
|
progress: SyncProgress,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Farbe animiert wechseln je nach State
|
||||||
|
val isError = progress.phase == SyncPhase.ERROR
|
||||||
|
val isCompleted = progress.phase == SyncPhase.COMPLETED
|
||||||
|
val isResult = isError || isCompleted
|
||||||
|
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isError -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
else -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
},
|
||||||
|
label = "bannerColor"
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isError -> MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
},
|
||||||
|
label = "bannerContentColor"
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = progress.isVisible,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = backgroundColor,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
|
) {
|
||||||
|
// Zeile 1: Icon + Phase/Message + Counter
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
// Icon: Spinner (aktiv), Checkmark (completed), Error (error)
|
||||||
|
when {
|
||||||
|
isCompleted -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = contentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isError -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ErrorOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = contentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = contentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text: Ergebnisnachricht oder Phase
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
isResult && !progress.resultMessage.isNullOrBlank() -> progress.resultMessage
|
||||||
|
else -> phaseToString(progress.phase)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Counter: x/y bei Uploads (Total bekannt), nur Zähler bei Downloads
|
||||||
|
if (!isResult && progress.current > 0) {
|
||||||
|
Text(
|
||||||
|
text = if (progress.total > 0) {
|
||||||
|
"${progress.current}/${progress.total}"
|
||||||
|
} else {
|
||||||
|
"${progress.current}"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = contentColor.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile 2: Progress Bar (nur bei Upload mit bekanntem Total)
|
||||||
|
if (!isResult && progress.total > 0 && progress.current > 0 &&
|
||||||
|
progress.phase == SyncPhase.UPLOADING) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress.progress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp),
|
||||||
|
color = contentColor,
|
||||||
|
trackColor = contentColor.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile 3: Aktueller Notiz-Titel (optional, nur bei aktivem Sync)
|
||||||
|
if (!isResult && !progress.currentFileName.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = progress.currentFileName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentColor.copy(alpha = 0.6f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert SyncPhase zu lokalisierten String
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun phaseToString(phase: SyncPhase): String {
|
||||||
|
return when (phase) {
|
||||||
|
SyncPhase.IDLE -> ""
|
||||||
|
SyncPhase.PREPARING -> stringResource(R.string.sync_phase_preparing)
|
||||||
|
SyncPhase.UPLOADING -> stringResource(R.string.sync_phase_uploading)
|
||||||
|
SyncPhase.DOWNLOADING -> stringResource(R.string.sync_phase_downloading)
|
||||||
|
SyncPhase.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown)
|
||||||
|
SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed)
|
||||||
|
SyncPhase.ERROR -> stringResource(R.string.sync_phase_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,8 @@ import androidx.compose.animation.expandVertically
|
|||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -25,6 +21,7 @@ import dev.dettmer.simplenotes.sync.SyncStateManager
|
|||||||
* Sync status banner shown below the toolbar during sync
|
* Sync status banner shown below the toolbar during sync
|
||||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen
|
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen
|
||||||
|
* v1.8.0: Nur noch COMPLETED/ERROR States - SYNCING wird von SyncProgressBanner übernommen
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SyncStatusBanner(
|
fun SyncStatusBanner(
|
||||||
@@ -32,10 +29,10 @@ fun SyncStatusBanner(
|
|||||||
message: String?,
|
message: String?,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
// v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund)
|
// v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner übernommen)
|
||||||
// Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT)
|
// IDLE und SYNCING_SILENT werden ignoriert
|
||||||
val isVisible = syncState != SyncStateManager.SyncState.IDLE
|
val isVisible = syncState == SyncStateManager.SyncState.COMPLETED
|
||||||
&& syncState != SyncStateManager.SyncState.SYNCING_SILENT
|
|| syncState == SyncStateManager.SyncState.ERROR
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
@@ -50,23 +47,13 @@ fun SyncStatusBanner(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (syncState == SyncStateManager.SyncState.SYNCING) {
|
// v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner übernommen
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 3.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = when (syncState) {
|
text = when (syncState) {
|
||||||
SyncStateManager.SyncState.SYNCING -> stringResource(R.string.sync_status_syncing)
|
|
||||||
SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false)
|
|
||||||
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
|
SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
|
||||||
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
|
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error)
|
||||||
SyncStateManager.SyncState.IDLE -> ""
|
else -> "" // SYNCING/IDLE/SYNCING_SILENT nicht mehr relevant
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material.icons.outlined.CloudDone
|
||||||
|
import androidx.compose.material.icons.outlined.CloudOff
|
||||||
|
import androidx.compose.material.icons.outlined.CloudSync
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Dialog showing the sync status icon legend
|
||||||
|
*
|
||||||
|
* Displays all 5 SyncStatus values with their icons, colors,
|
||||||
|
* and descriptions. Helps users understand what each icon means.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SyncStatusLegendDialog(
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_legend_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Optional: Kurze Einleitung
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_legend_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// ☁️✓ SYNCED
|
||||||
|
LegendRow(
|
||||||
|
icon = Icons.Outlined.CloudDone,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
label = stringResource(R.string.sync_legend_synced_label),
|
||||||
|
description = stringResource(R.string.sync_legend_synced_desc)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ☁️↻ PENDING
|
||||||
|
LegendRow(
|
||||||
|
icon = Icons.Outlined.CloudSync,
|
||||||
|
tint = MaterialTheme.colorScheme.outline,
|
||||||
|
label = stringResource(R.string.sync_legend_pending_label),
|
||||||
|
description = stringResource(R.string.sync_legend_pending_desc)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ⚠️ CONFLICT
|
||||||
|
LegendRow(
|
||||||
|
icon = Icons.Default.Warning,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
label = stringResource(R.string.sync_legend_conflict_label),
|
||||||
|
description = stringResource(R.string.sync_legend_conflict_desc)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ☁️✗ LOCAL_ONLY
|
||||||
|
LegendRow(
|
||||||
|
icon = Icons.Outlined.CloudOff,
|
||||||
|
tint = MaterialTheme.colorScheme.outline,
|
||||||
|
label = stringResource(R.string.sync_legend_local_only_label),
|
||||||
|
description = stringResource(R.string.sync_legend_local_only_desc)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ☁️✗ DELETED_ON_SERVER
|
||||||
|
LegendRow(
|
||||||
|
icon = Icons.Outlined.CloudOff,
|
||||||
|
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||||
|
label = stringResource(R.string.sync_legend_deleted_label),
|
||||||
|
description = stringResource(R.string.sync_legend_deleted_desc)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single row in the sync status legend
|
||||||
|
* Shows icon + label + description
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LegendRow(
|
||||||
|
icon: ImageVector,
|
||||||
|
tint: Color,
|
||||||
|
label: String,
|
||||||
|
description: String
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null, // Dekorativ, Label reicht
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.dettmer.simplenotes.ui.settings
|
package dev.dettmer.simplenotes.ui.settings
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -149,7 +150,12 @@ class ComposeSettingsActivity : AppCompatActivity() {
|
|||||||
/**
|
/**
|
||||||
* Open system battery optimization settings
|
* Open system battery optimization settings
|
||||||
* v1.5.0: Ported from old SettingsActivity
|
* v1.5.0: Ported from old SettingsActivity
|
||||||
|
*
|
||||||
|
* Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds.
|
||||||
|
* For Play Store builds, this would need to be changed to
|
||||||
|
* ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly).
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
private fun openBatteryOptimizationSettings() {
|
private fun openBatteryOptimizationSettings() {
|
||||||
try {
|
try {
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
@@ -183,4 +189,16 @@ class ComposeSettingsActivity : AppCompatActivity() {
|
|||||||
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}")
|
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle configuration changes (e.g., locale) without recreating activity
|
||||||
|
* v1.8.0: Prevents flickering during language changes by avoiding full recreate
|
||||||
|
* Compose automatically recomposes when configuration changes
|
||||||
|
*/
|
||||||
|
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
Logger.d(TAG, "📱 Configuration changed (likely locale switch) - Compose will recompose")
|
||||||
|
// Compose handles UI updates automatically via recomposition
|
||||||
|
// No manual action needed - stringResource() etc. will pick up new locale
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import dev.dettmer.simplenotes.sync.WebDavSyncService
|
|||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -40,6 +41,8 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SettingsViewModel"
|
private const val TAG = "SettingsViewModel"
|
||||||
private const val CONNECTION_TIMEOUT_MS = 3000
|
private const val CONNECTION_TIMEOUT_MS = 3000
|
||||||
|
private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations
|
||||||
|
private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
@@ -135,6 +138,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
|
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Max Parallel Downloads
|
||||||
|
private val _maxParallelDownloads = MutableStateFlow(
|
||||||
|
prefs.getInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, Constants.DEFAULT_MAX_PARALLEL_DOWNLOADS)
|
||||||
|
)
|
||||||
|
val maxParallelDownloads: StateFlow<Int> = _maxParallelDownloads.asStateFlow()
|
||||||
|
|
||||||
// 🌟 v1.6.0: Configurable Sync Triggers
|
// 🌟 v1.6.0: Configurable Sync Triggers
|
||||||
private val _triggerOnSave = MutableStateFlow(
|
private val _triggerOnSave = MutableStateFlow(
|
||||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
|
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
|
||||||
@@ -205,6 +214,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val _isBackupInProgress = MutableStateFlow(false)
|
private val _isBackupInProgress = MutableStateFlow(false)
|
||||||
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
|
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
|
||||||
|
|
||||||
|
// v1.8.0: Descriptive backup status text
|
||||||
|
private val _backupStatusText = MutableStateFlow("")
|
||||||
|
val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow()
|
||||||
|
|
||||||
private val _showToast = MutableSharedFlow<String>()
|
private val _showToast = MutableSharedFlow<String>()
|
||||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||||
|
|
||||||
@@ -497,6 +510,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Max Parallel Downloads Setter
|
||||||
|
fun setMaxParallelDownloads(count: Int) {
|
||||||
|
val validCount = count.coerceIn(
|
||||||
|
Constants.MIN_PARALLEL_DOWNLOADS,
|
||||||
|
Constants.MAX_PARALLEL_DOWNLOADS
|
||||||
|
)
|
||||||
|
_maxParallelDownloads.value = validCount
|
||||||
|
prefs.edit().putInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, validCount).apply()
|
||||||
|
}
|
||||||
|
|
||||||
// 🌟 v1.6.0: Configurable Sync Triggers Setters
|
// 🌟 v1.6.0: Configurable Sync Triggers Setters
|
||||||
|
|
||||||
fun setTriggerOnSave(enabled: Boolean) {
|
fun setTriggerOnSave(enabled: Boolean) {
|
||||||
@@ -655,18 +678,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun createBackup(uri: Uri, password: String? = null) {
|
fun createBackup(uri: Uri, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
|
_backupStatusText.value = getString(R.string.backup_progress_creating)
|
||||||
try {
|
try {
|
||||||
val result = backupManager.createBackup(uri, password)
|
val result = backupManager.createBackup(uri, password)
|
||||||
val message = if (result.success) {
|
|
||||||
getString(R.string.toast_backup_success, result.message ?: "")
|
// Phase 2: Show completion status
|
||||||
|
_backupStatusText.value = if (result.success) {
|
||||||
|
getString(R.string.backup_progress_complete)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.toast_backup_failed, result.error ?: "")
|
getString(R.string.backup_progress_failed)
|
||||||
}
|
}
|
||||||
emitToast(message)
|
|
||||||
|
// Phase 3: Clear after delay
|
||||||
|
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
emitToast(getString(R.string.toast_backup_failed, e.message ?: ""))
|
Logger.e(TAG, "Failed to create backup", e)
|
||||||
|
_backupStatusText.value = getString(R.string.backup_progress_failed)
|
||||||
|
delay(STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
} finally {
|
} finally {
|
||||||
_isBackupInProgress.value = false
|
_isBackupInProgress.value = false
|
||||||
|
_backupStatusText.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,18 +706,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
|
_backupStatusText.value = getString(R.string.backup_progress_restoring)
|
||||||
try {
|
try {
|
||||||
val result = backupManager.restoreBackup(uri, mode, password)
|
val result = backupManager.restoreBackup(uri, mode, password)
|
||||||
val message = if (result.success) {
|
|
||||||
getString(R.string.toast_restore_success, result.importedNotes)
|
// Phase 2: Show completion status
|
||||||
|
_backupStatusText.value = if (result.success) {
|
||||||
|
getString(R.string.restore_progress_complete)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.toast_restore_failed, result.error ?: "")
|
getString(R.string.restore_progress_failed)
|
||||||
}
|
}
|
||||||
emitToast(message)
|
|
||||||
|
// Phase 3: Clear after delay
|
||||||
|
delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
emitToast(getString(R.string.toast_restore_failed, e.message ?: ""))
|
Logger.e(TAG, "Failed to restore backup from file", e)
|
||||||
|
_backupStatusText.value = getString(R.string.restore_progress_failed)
|
||||||
|
delay(STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
} finally {
|
} finally {
|
||||||
_isBackupInProgress.value = false
|
_isBackupInProgress.value = false
|
||||||
|
_backupStatusText.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,22 +757,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun restoreFromServer(mode: RestoreMode) {
|
fun restoreFromServer(mode: RestoreMode) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
|
_backupStatusText.value = getString(R.string.backup_progress_restoring_server)
|
||||||
try {
|
try {
|
||||||
emitToast(getString(R.string.restore_progress))
|
|
||||||
val syncService = WebDavSyncService(getApplication())
|
val syncService = WebDavSyncService(getApplication())
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
syncService.restoreFromServer(mode)
|
syncService.restoreFromServer(mode)
|
||||||
}
|
}
|
||||||
val message = if (result.isSuccess) {
|
|
||||||
getString(R.string.toast_restore_success, result.restoredCount)
|
// Phase 2: Show completion status
|
||||||
|
_backupStatusText.value = if (result.isSuccess) {
|
||||||
|
getString(R.string.restore_server_progress_complete)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.toast_restore_failed, result.errorMessage ?: "")
|
getString(R.string.restore_server_progress_failed)
|
||||||
}
|
}
|
||||||
emitToast(message)
|
|
||||||
|
// Phase 3: Clear after delay
|
||||||
|
delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
emitToast(getString(R.string.toast_error, e.message ?: ""))
|
Logger.e(TAG, "Failed to restore from server", e)
|
||||||
|
_backupStatusText.value = getString(R.string.restore_server_progress_failed)
|
||||||
|
delay(STATUS_CLEAR_DELAY_ERROR_MS)
|
||||||
} finally {
|
} finally {
|
||||||
_isBackupInProgress.value = false
|
_isBackupInProgress.value = false
|
||||||
|
_backupStatusText.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -762,6 +811,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
fun getLogFile() = Logger.getLogFile(getApplication())
|
fun getLogFile() = Logger.getLogFile(getApplication())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.8.0: Reset changelog version to force showing the changelog dialog on next start
|
||||||
|
* Used for testing the post-update changelog feature
|
||||||
|
*/
|
||||||
|
fun resetChangelogVersion() {
|
||||||
|
prefs.edit()
|
||||||
|
.putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Helper
|
// Helper
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package dev.dettmer.simplenotes.ui.settings.components
|
package dev.dettmer.simplenotes.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -11,12 +15,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary filled button for settings actions
|
* Primary filled button for settings actions
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
|
* v1.8.0: Button keeps text during loading, just becomes disabled
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsButton(
|
fun SettingsButton(
|
||||||
@@ -31,20 +37,13 @@ fun SettingsButton(
|
|||||||
enabled = enabled && !isLoading,
|
enabled = enabled && !isLoading,
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
Text(text)
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.height(20.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outlined secondary button for settings actions
|
* Outlined secondary button for settings actions
|
||||||
|
* v1.8.0: Button keeps text during loading, just becomes disabled
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsOutlinedButton(
|
fun SettingsOutlinedButton(
|
||||||
@@ -59,15 +58,7 @@ fun SettingsOutlinedButton(
|
|||||||
enabled = enabled && !isLoading,
|
enabled = enabled && !isLoading,
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
Text(text)
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.height(20.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,3 +150,48 @@ fun SettingsDivider(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.8.0: Backup progress indicator shown above buttons
|
||||||
|
* Replaces the ugly in-button spinner with a clear status display
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BackupProgressCard(
|
||||||
|
statusText: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
androidx.compose.material3.LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Code
|
import androidx.compose.material.icons.filled.Code
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Policy
|
import androidx.compose.material.icons.filled.Policy
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -56,6 +57,7 @@ fun AboutScreen(
|
|||||||
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
|
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
|
||||||
val githubProfileUrl = "https://github.com/inventory69"
|
val githubProfileUrl = "https://github.com/inventory69"
|
||||||
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
||||||
|
val changelogUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md" // v1.8.0
|
||||||
|
|
||||||
SettingsScaffold(
|
SettingsScaffold(
|
||||||
title = stringResource(R.string.about_settings_title),
|
title = stringResource(R.string.about_settings_title),
|
||||||
@@ -162,6 +164,17 @@ fun AboutScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// v1.8.0: Changelog
|
||||||
|
AboutLinkItem(
|
||||||
|
icon = Icons.Default.History,
|
||||||
|
title = stringResource(R.string.about_changelog_title),
|
||||||
|
subtitle = stringResource(R.string.about_changelog_subtitle),
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(changelogUrl))
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
|
|
||||||
// Data Privacy Info
|
// Data Privacy Info
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -27,6 +28,7 @@ import dev.dettmer.simplenotes.R
|
|||||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
|
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.BackupProgressCard
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||||
@@ -39,6 +41,10 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
// v1.8.0: Delay for dialog close animation before starting restore
|
||||||
|
private const val DIALOG_CLOSE_DELAY_MS = 200L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup and restore settings screen
|
* Backup and restore settings screen
|
||||||
@@ -60,6 +66,10 @@ fun BackupSettingsScreen(
|
|||||||
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
||||||
|
|
||||||
|
// v1.8.0: Trigger for delayed restore execution (after dialog closes)
|
||||||
|
var triggerRestore by remember { mutableStateOf(0) }
|
||||||
|
var pendingRestoreAction by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
|
||||||
// 🔐 v1.7.0: Encryption state
|
// 🔐 v1.7.0: Encryption state
|
||||||
var encryptBackup by remember { mutableStateOf(false) }
|
var encryptBackup by remember { mutableStateOf(false) }
|
||||||
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
|
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||||
@@ -91,6 +101,15 @@ fun BackupSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.8.0: Delayed restore execution after dialog closes
|
||||||
|
LaunchedEffect(triggerRestore) {
|
||||||
|
if (triggerRestore > 0) {
|
||||||
|
delay(DIALOG_CLOSE_DELAY_MS) // Wait for dialog close animation
|
||||||
|
pendingRestoreAction?.invoke()
|
||||||
|
pendingRestoreAction = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsScaffold(
|
SettingsScaffold(
|
||||||
title = stringResource(R.string.backup_settings_title),
|
title = stringResource(R.string.backup_settings_title),
|
||||||
onBack = onBack
|
onBack = onBack
|
||||||
@@ -108,6 +127,16 @@ fun BackupSettingsScreen(
|
|||||||
text = stringResource(R.string.backup_auto_info)
|
text = stringResource(R.string.backup_auto_info)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// v1.8.0: Progress indicator (visible during backup/restore)
|
||||||
|
if (isBackupInProgress) {
|
||||||
|
val backupStatus by viewModel.backupStatusText.collectAsState()
|
||||||
|
BackupProgressCard(
|
||||||
|
statusText = backupStatus.ifEmpty {
|
||||||
|
stringResource(R.string.backup_progress_creating)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Local Backup Section
|
// Local Backup Section
|
||||||
@@ -234,21 +263,29 @@ fun BackupSettingsScreen(
|
|||||||
when (restoreSource) {
|
when (restoreSource) {
|
||||||
RestoreSource.LocalFile -> {
|
RestoreSource.LocalFile -> {
|
||||||
pendingRestoreUri?.let { uri ->
|
pendingRestoreUri?.let { uri ->
|
||||||
// 🔐 v1.7.0: Check if backup is encrypted
|
// v1.8.0: Schedule restore with delay for dialog close
|
||||||
viewModel.checkBackupEncryption(
|
pendingRestoreAction = {
|
||||||
uri = uri,
|
// 🔐 v1.7.0: Check if backup is encrypted
|
||||||
onEncrypted = {
|
viewModel.checkBackupEncryption(
|
||||||
showDecryptionPasswordDialog = true
|
uri = uri,
|
||||||
},
|
onEncrypted = {
|
||||||
onPlaintext = {
|
showDecryptionPasswordDialog = true
|
||||||
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
|
},
|
||||||
pendingRestoreUri = null
|
onPlaintext = {
|
||||||
}
|
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
|
||||||
)
|
pendingRestoreUri = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
triggerRestore++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RestoreSource.Server -> {
|
RestoreSource.Server -> {
|
||||||
viewModel.restoreFromServer(selectedRestoreMode)
|
// v1.8.0: Schedule restore with delay for dialog close
|
||||||
|
pendingRestoreAction = {
|
||||||
|
viewModel.restoreFromServer(selectedRestoreMode)
|
||||||
|
}
|
||||||
|
triggerRestore++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,6 +119,31 @@ fun DebugSettingsScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// v1.8.0: Test Mode Section
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.debug_test_section))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Info about test mode
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.debug_reset_changelog_desc)
|
||||||
|
)
|
||||||
|
|
||||||
|
val changelogResetToast = stringResource(R.string.debug_changelog_reset)
|
||||||
|
|
||||||
|
SettingsButton(
|
||||||
|
text = stringResource(R.string.debug_reset_changelog),
|
||||||
|
onClick = {
|
||||||
|
viewModel.resetChangelogVersion()
|
||||||
|
android.widget.Toast.makeText(context, changelogResetToast, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package dev.dettmer.simplenotes.ui.settings.screens
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -15,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
@@ -35,8 +33,6 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
|||||||
fun LanguageSettingsScreen(
|
fun LanguageSettingsScreen(
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// Get current app locale - fresh value each time (no remember, always reads current state)
|
// Get current app locale - fresh value each time (no remember, always reads current state)
|
||||||
val currentLocale = AppCompatDelegate.getApplicationLocales()
|
val currentLocale = AppCompatDelegate.getApplicationLocales()
|
||||||
val currentLanguageCode = if (currentLocale.isEmpty) {
|
val currentLanguageCode = if (currentLocale.isEmpty) {
|
||||||
@@ -92,7 +88,7 @@ fun LanguageSettingsScreen(
|
|||||||
onValueSelected = { newLanguage ->
|
onValueSelected = { newLanguage ->
|
||||||
if (newLanguage != selectedLanguage) {
|
if (newLanguage != selectedLanguage) {
|
||||||
selectedLanguage = newLanguage
|
selectedLanguage = newLanguage
|
||||||
setAppLanguage(newLanguage, context as Activity)
|
setAppLanguage(newLanguage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -102,19 +98,19 @@ fun LanguageSettingsScreen(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set app language using AppCompatDelegate
|
* Set app language using AppCompatDelegate
|
||||||
* Works on Android 13+ natively, falls back to AppCompat on older versions
|
* v1.8.0: Smooth language change without activity recreate
|
||||||
|
*
|
||||||
|
* ComposeSettingsActivity handles locale changes via android:configChanges="locale"
|
||||||
|
* in AndroidManifest.xml, preventing full activity recreate and eliminating flicker.
|
||||||
|
* Compose automatically recomposes when the configuration changes.
|
||||||
*/
|
*/
|
||||||
private fun setAppLanguage(languageCode: String, activity: Activity) {
|
private fun setAppLanguage(languageCode: String) {
|
||||||
val localeList = if (languageCode.isEmpty()) {
|
val localeList = if (languageCode.isEmpty()) {
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
} else {
|
} else {
|
||||||
LocaleListCompat.forLanguageTags(languageCode)
|
LocaleListCompat.forLanguageTags(languageCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets the app locale - triggers onConfigurationChanged() instead of recreate()
|
||||||
AppCompatDelegate.setApplicationLocales(localeList)
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
|
|
||||||
// Restart the activity to apply the change
|
|
||||||
// On Android 13+ the system handles this automatically for some apps,
|
|
||||||
// but we need to recreate to ensure our Compose UI recomposes with new locale
|
|
||||||
activity.recreate()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
|||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync settings screen - Configurable Sync Triggers
|
* Sync settings screen — Restructured for v1.8.0
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
*
|
||||||
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
|
* Two clear sections:
|
||||||
|
* 1. Sync Triggers (all 5 triggers grouped logically)
|
||||||
|
* 2. Network & Performance (WiFi-only + Parallel Downloads)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SyncSettingsScreen(
|
fun SyncSettingsScreen(
|
||||||
@@ -50,10 +52,9 @@ fun SyncSettingsScreen(
|
|||||||
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||||
|
|
||||||
// 🆕 v1.7.0: WiFi-only sync
|
val maxParallelDownloads by viewModel.maxParallelDownloads.collectAsState()
|
||||||
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
|
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
|
||||||
|
|
||||||
// Check if server is configured
|
|
||||||
val isServerConfigured = viewModel.isServerConfigured()
|
val isServerConfigured = viewModel.isServerConfigured()
|
||||||
|
|
||||||
SettingsScaffold(
|
SettingsScaffold(
|
||||||
@@ -68,7 +69,7 @@ fun SyncSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// 🌟 v1.6.0: Offline Mode Warning if server not configured
|
// ── Offline Mode Warning ──
|
||||||
if (!isServerConfigured) {
|
if (!isServerConfigured) {
|
||||||
SettingsInfoCard(
|
SettingsInfoCard(
|
||||||
text = stringResource(R.string.sync_offline_mode_message),
|
text = stringResource(R.string.sync_offline_mode_message),
|
||||||
@@ -86,37 +87,14 @@ fun SyncSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// 🆕 v1.7.0: NETZWERK-EINSCHRÄNKUNG Section (Global für alle Trigger)
|
// SECTION 1: SYNC TRIGGERS
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_network))
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_triggers))
|
||||||
|
|
||||||
// WiFi-Only Sync Toggle - Gilt für ALLE Trigger außer WiFi-Connect
|
|
||||||
SettingsSwitch(
|
|
||||||
title = stringResource(R.string.sync_wifi_only_title),
|
|
||||||
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
|
|
||||||
checked = wifiOnlySync,
|
|
||||||
onCheckedChange = { viewModel.setWifiOnlySync(it) },
|
|
||||||
icon = Icons.Default.Wifi,
|
|
||||||
enabled = isServerConfigured
|
|
||||||
)
|
|
||||||
|
|
||||||
// Info-Hinweis dass WiFi-Connect davon ausgenommen ist
|
|
||||||
if (wifiOnlySync && isServerConfigured) {
|
|
||||||
SettingsInfoCard(
|
|
||||||
text = stringResource(R.string.sync_wifi_only_hint)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsDivider()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// SOFORT-SYNC Section
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
|
// ── Sofort-Sync ──
|
||||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
|
||||||
|
|
||||||
// onSave Trigger
|
|
||||||
SettingsSwitch(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.sync_trigger_on_save_title),
|
title = stringResource(R.string.sync_trigger_on_save_title),
|
||||||
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
|
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
|
||||||
@@ -126,7 +104,6 @@ fun SyncSettingsScreen(
|
|||||||
enabled = isServerConfigured
|
enabled = isServerConfigured
|
||||||
)
|
)
|
||||||
|
|
||||||
// onResume Trigger
|
|
||||||
SettingsSwitch(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.sync_trigger_on_resume_title),
|
title = stringResource(R.string.sync_trigger_on_resume_title),
|
||||||
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
|
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
|
||||||
@@ -136,15 +113,11 @@ fun SyncSettingsScreen(
|
|||||||
enabled = isServerConfigured
|
enabled = isServerConfigured
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsDivider()
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// HINTERGRUND-SYNC Section
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
|
// ── Hintergrund-Sync ──
|
||||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
|
||||||
|
|
||||||
// WiFi-Connect Trigger
|
|
||||||
SettingsSwitch(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.sync_trigger_wifi_connect_title),
|
title = stringResource(R.string.sync_trigger_wifi_connect_title),
|
||||||
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
|
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
|
||||||
@@ -154,7 +127,6 @@ fun SyncSettingsScreen(
|
|||||||
enabled = isServerConfigured
|
enabled = isServerConfigured
|
||||||
)
|
)
|
||||||
|
|
||||||
// Periodic Trigger
|
|
||||||
SettingsSwitch(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.sync_trigger_periodic_title),
|
title = stringResource(R.string.sync_trigger_periodic_title),
|
||||||
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
|
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
|
||||||
@@ -164,7 +136,7 @@ fun SyncSettingsScreen(
|
|||||||
enabled = isServerConfigured
|
enabled = isServerConfigured
|
||||||
)
|
)
|
||||||
|
|
||||||
// Periodic Interval Selection (only visible if periodic trigger is enabled)
|
// Interval-Auswahl (nur sichtbar wenn Periodic aktiv)
|
||||||
if (triggerPeriodic && isServerConfigured) {
|
if (triggerPeriodic && isServerConfigured) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
@@ -195,15 +167,6 @@ fun SyncSettingsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsDivider()
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// ADVANCED Section (Boot Sync)
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
|
|
||||||
|
|
||||||
// Boot Trigger
|
|
||||||
SettingsSwitch(
|
SettingsSwitch(
|
||||||
title = stringResource(R.string.sync_trigger_boot_title),
|
title = stringResource(R.string.sync_trigger_boot_title),
|
||||||
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
|
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
|
||||||
@@ -213,9 +176,9 @@ fun SyncSettingsScreen(
|
|||||||
enabled = isServerConfigured
|
enabled = isServerConfigured
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsDivider()
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Manual Sync Info
|
// ── Info Card ──
|
||||||
val manualHintText = if (isServerConfigured) {
|
val manualHintText = if (isServerConfigured) {
|
||||||
stringResource(R.string.sync_manual_hint)
|
stringResource(R.string.sync_manual_hint)
|
||||||
} else {
|
} else {
|
||||||
@@ -226,6 +189,68 @@ fun SyncSettingsScreen(
|
|||||||
text = manualHintText
|
text = manualHintText
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// SECTION 2: NETZWERK & PERFORMANCE
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_network_performance))
|
||||||
|
|
||||||
|
// WiFi-Only Toggle
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_wifi_only_title),
|
||||||
|
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
|
||||||
|
checked = wifiOnlySync,
|
||||||
|
onCheckedChange = { viewModel.setWifiOnlySync(it) },
|
||||||
|
icon = Icons.Default.Wifi,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
if (wifiOnlySync && isServerConfigured) {
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.sync_wifi_only_hint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Parallel Downloads
|
||||||
|
val parallelOptions = listOf(
|
||||||
|
RadioOption(
|
||||||
|
value = 1,
|
||||||
|
title = "1 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||||
|
subtitle = stringResource(R.string.sync_parallel_downloads_desc_1)
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = 3,
|
||||||
|
title = "3 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||||
|
subtitle = stringResource(R.string.sync_parallel_downloads_desc_3)
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = 5,
|
||||||
|
title = "5 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||||
|
subtitle = stringResource(R.string.sync_parallel_downloads_desc_5)
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = 7,
|
||||||
|
title = "7 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||||
|
subtitle = stringResource(R.string.sync_parallel_downloads_desc_7)
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = 10,
|
||||||
|
title = "10 ${stringResource(R.string.sync_parallel_downloads_unit)}",
|
||||||
|
subtitle = stringResource(R.string.sync_parallel_downloads_desc_10)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsRadioGroup(
|
||||||
|
title = stringResource(R.string.sync_parallel_downloads_title),
|
||||||
|
options = parallelOptions,
|
||||||
|
selectedValue = maxParallelDownloads,
|
||||||
|
onValueSelected = { viewModel.setMaxParallelDownloads(it) }
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,22 @@ object Constants {
|
|||||||
|
|
||||||
// 🎨 v1.7.0: Staggered Grid Layout
|
// 🎨 v1.7.0: Staggered Grid Layout
|
||||||
const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid"
|
const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid"
|
||||||
const val DEFAULT_DISPLAY_MODE = "list"
|
const val DEFAULT_DISPLAY_MODE = "grid" // v1.8.0: Grid als Standard-Ansicht
|
||||||
const val GRID_COLUMNS = 2
|
const val GRID_COLUMNS = 2
|
||||||
const val GRID_SPACING_DP = 8
|
const val GRID_SPACING_DP = 8
|
||||||
|
|
||||||
|
// ⚡ v1.8.0: Parallel Downloads
|
||||||
|
const val KEY_MAX_PARALLEL_DOWNLOADS = "max_parallel_downloads"
|
||||||
|
const val DEFAULT_MAX_PARALLEL_DOWNLOADS = 5
|
||||||
|
const val MIN_PARALLEL_DOWNLOADS = 1
|
||||||
|
const val MAX_PARALLEL_DOWNLOADS = 10
|
||||||
|
|
||||||
|
// 🔀 v1.8.0: Sortierung
|
||||||
|
const val KEY_SORT_OPTION = "sort_option"
|
||||||
|
const val KEY_SORT_DIRECTION = "sort_direction"
|
||||||
|
const val DEFAULT_SORT_OPTION = "updatedAt"
|
||||||
|
const val DEFAULT_SORT_DIRECTION = "desc"
|
||||||
|
|
||||||
|
// 📋 v1.8.0: Post-Update Changelog
|
||||||
|
const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Homescreen Widget für Notizen und Checklisten
|
||||||
|
*
|
||||||
|
* Unterstützt fünf responsive Größen für breite und schmale Layouts:
|
||||||
|
* - SMALL (110x80dp): Nur Titel
|
||||||
|
* - NARROW_MEDIUM (110x110dp): Schmal + Vorschau / kompakte Checkliste
|
||||||
|
* - NARROW_LARGE (110x250dp): Schmal + voller Inhalt
|
||||||
|
* - WIDE_MEDIUM (250x110dp): Breit + Vorschau
|
||||||
|
* - WIDE_LARGE (250x250dp): Breit + voller Inhalt / interaktive Checkliste
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Material You Dynamic Colors
|
||||||
|
* - Interaktive Checklist-Checkboxen
|
||||||
|
* - Sperr-Funktion gegen versehentliches Bearbeiten
|
||||||
|
* - Tap-to-Edit (öffnet NoteEditor)
|
||||||
|
* - Einstellbare Hintergrund-Transparenz
|
||||||
|
* - Permanenter Options-Button (⋮)
|
||||||
|
* - NoteType-differenzierte Icons
|
||||||
|
*/
|
||||||
|
class NoteWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Responsive Breakpoints — schmale + breite Spalten
|
||||||
|
val SIZE_SMALL = DpSize(110.dp, 80.dp) // Schmal+kurz: nur Titel
|
||||||
|
val SIZE_NARROW_MEDIUM = DpSize(110.dp, 110.dp) // Schmal+mittel: Vorschau
|
||||||
|
val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt
|
||||||
|
val SIZE_WIDE_MEDIUM = DpSize(250.dp, 110.dp) // Breit+mittel: Vorschau
|
||||||
|
val SIZE_WIDE_LARGE = DpSize(250.dp, 250.dp) // Breit+groß: voller Inhalt
|
||||||
|
}
|
||||||
|
|
||||||
|
override val sizeMode = SizeMode.Responsive(
|
||||||
|
setOf(SIZE_SMALL, SIZE_NARROW_MEDIUM, SIZE_NARROW_LARGE, SIZE_WIDE_MEDIUM, SIZE_WIDE_LARGE)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val storage = NotesStorage(context)
|
||||||
|
|
||||||
|
provideContent {
|
||||||
|
val prefs = currentState<Preferences>()
|
||||||
|
val noteId = prefs[NoteWidgetState.KEY_NOTE_ID]
|
||||||
|
val isLocked = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false
|
||||||
|
val showOptions = prefs[NoteWidgetState.KEY_SHOW_OPTIONS] ?: false
|
||||||
|
val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
|
||||||
|
|
||||||
|
val note = noteId?.let { nId ->
|
||||||
|
storage.loadNote(nId)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlanceTheme {
|
||||||
|
NoteWidgetContent(
|
||||||
|
note = note,
|
||||||
|
isLocked = isLocked,
|
||||||
|
showOptions = showOptions,
|
||||||
|
bgOpacity = bgOpacity,
|
||||||
|
glanceId = id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.action.ActionParameters
|
||||||
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen
|
||||||
|
*
|
||||||
|
* Shared Keys für alle ActionCallback-Klassen.
|
||||||
|
*/
|
||||||
|
object NoteWidgetActionKeys {
|
||||||
|
val KEY_NOTE_ID = ActionParameters.Key<String>("noteId")
|
||||||
|
val KEY_ITEM_ID = ActionParameters.Key<String>("itemId")
|
||||||
|
val KEY_GLANCE_ID = ActionParameters.Key<String>("glanceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 FIX: Checklist-Item abhaken/enthaken
|
||||||
|
*
|
||||||
|
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
|
||||||
|
*
|
||||||
|
* - Toggelt isChecked im JSON-File
|
||||||
|
* - Setzt SyncStatus auf PENDING
|
||||||
|
* - Aktualisiert Widget sofort
|
||||||
|
*/
|
||||||
|
class ToggleChecklistItemAction : ActionCallback {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ToggleChecklistItem"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onAction(
|
||||||
|
context: Context,
|
||||||
|
glanceId: GlanceId,
|
||||||
|
parameters: ActionParameters
|
||||||
|
) {
|
||||||
|
val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return
|
||||||
|
val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return
|
||||||
|
|
||||||
|
val storage = NotesStorage(context)
|
||||||
|
val note = storage.loadNote(noteId) ?: return
|
||||||
|
|
||||||
|
val updatedItems = note.checklistItems?.map { item ->
|
||||||
|
if (item.id == itemId) {
|
||||||
|
item.copy(isChecked = !item.isChecked)
|
||||||
|
} else item
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
val updatedNote = note.copy(
|
||||||
|
checklistItems = updatedItems,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
storage.saveNote(updatedNote)
|
||||||
|
Logger.d(TAG, "Toggled checklist item '$itemId' in widget")
|
||||||
|
|
||||||
|
// 🐛 FIX: Glance-State ändern um Re-Render zu erzwingen
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
prefs[NoteWidgetState.KEY_LAST_UPDATED] = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget aktualisieren — Glance erkennt jetzt den State-Change
|
||||||
|
NoteWidget().update(context, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 FIX: Widget sperren/entsperren
|
||||||
|
*
|
||||||
|
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
|
||||||
|
*/
|
||||||
|
class ToggleLockAction : ActionCallback {
|
||||||
|
override suspend fun onAction(
|
||||||
|
context: Context,
|
||||||
|
glanceId: GlanceId,
|
||||||
|
parameters: ActionParameters
|
||||||
|
) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
val currentLock = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false
|
||||||
|
prefs[NoteWidgetState.KEY_IS_LOCKED] = !currentLock
|
||||||
|
// Options ausblenden nach Toggle
|
||||||
|
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteWidget().update(context, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 FIX: Optionsleiste ein-/ausblenden
|
||||||
|
*
|
||||||
|
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
|
||||||
|
*/
|
||||||
|
class ShowOptionsAction : ActionCallback {
|
||||||
|
override suspend fun onAction(
|
||||||
|
context: Context,
|
||||||
|
glanceId: GlanceId,
|
||||||
|
parameters: ActionParameters
|
||||||
|
) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
val currentShow = prefs[NoteWidgetState.KEY_SHOW_OPTIONS] ?: false
|
||||||
|
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = !currentShow
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteWidget().update(context, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 FIX: Widget-Daten neu laden
|
||||||
|
*
|
||||||
|
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
|
||||||
|
*/
|
||||||
|
class RefreshAction : ActionCallback {
|
||||||
|
override suspend fun onAction(
|
||||||
|
context: Context,
|
||||||
|
glanceId: GlanceId,
|
||||||
|
parameters: ActionParameters
|
||||||
|
) {
|
||||||
|
// Options ausblenden
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteWidget().update(context, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Widget-Konfiguration öffnen (Reconfigure)
|
||||||
|
*
|
||||||
|
* Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität.
|
||||||
|
* Öffnet die Config-Activity im Reconfigure-Modus.
|
||||||
|
*/
|
||||||
|
class OpenConfigAction : ActionCallback {
|
||||||
|
override suspend fun onAction(
|
||||||
|
context: Context,
|
||||||
|
glanceId: GlanceId,
|
||||||
|
parameters: ActionParameters
|
||||||
|
) {
|
||||||
|
// Options ausblenden
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config-Activity als Reconfigure öffnen
|
||||||
|
val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context)
|
||||||
|
val appWidgetId = glanceManager.getAppWidgetId(glanceId)
|
||||||
|
|
||||||
|
val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply {
|
||||||
|
putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
// 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt
|
||||||
|
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets
|
||||||
|
*
|
||||||
|
* Zeigt eine Liste aller Notizen. User wählt eine aus,
|
||||||
|
* die dann im Widget angezeigt wird.
|
||||||
|
*
|
||||||
|
* Optionen:
|
||||||
|
* - Notiz auswählen
|
||||||
|
* - Widget initial sperren (optional)
|
||||||
|
* - Hintergrund-Transparenz einstellen
|
||||||
|
*
|
||||||
|
* Unterstützt Reconfiguration (Android 12+): Beim erneuten Öffnen
|
||||||
|
* werden die bestehenden Einstellungen als Defaults geladen.
|
||||||
|
*
|
||||||
|
* 🆕 v1.8.0 (IMPL_025): Auto-Save bei Back-Navigation + Save-FAB
|
||||||
|
*/
|
||||||
|
class NoteWidgetConfigActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): State-Tracking für Auto-Save bei Back-Navigation
|
||||||
|
private var currentSelectedNoteId: String? = null
|
||||||
|
private var currentLockState: Boolean = false
|
||||||
|
private var currentOpacity: Float = 1.0f
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Default-Result: Cancelled (falls User zurück-navigiert)
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Auto-Save bei Back-Navigation
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
// Auto-Save nur bei Reconfigure (wenn bereits eine Note konfiguriert war)
|
||||||
|
if (currentSelectedNoteId != null) {
|
||||||
|
configureWidget(currentSelectedNoteId!!, currentLockState, currentOpacity)
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Widget-ID aus Intent
|
||||||
|
appWidgetId = intent?.extras?.getInt(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val storage = NotesStorage(this)
|
||||||
|
|
||||||
|
// Bestehende Konfiguration laden (für Reconfigure)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
var existingNoteId: String? = null
|
||||||
|
var existingLock = false
|
||||||
|
var existingOpacity = 1.0f
|
||||||
|
|
||||||
|
try {
|
||||||
|
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
|
||||||
|
.getGlanceIdBy(appWidgetId)
|
||||||
|
val prefs = getAppWidgetState(
|
||||||
|
this@NoteWidgetConfigActivity,
|
||||||
|
PreferencesGlanceStateDefinition,
|
||||||
|
glanceId
|
||||||
|
)
|
||||||
|
existingNoteId = prefs[NoteWidgetState.KEY_NOTE_ID]
|
||||||
|
existingLock = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false
|
||||||
|
existingOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Neues Widget — keine bestehende Konfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Initiale State-Werte für Auto-Save setzen
|
||||||
|
currentSelectedNoteId = existingNoteId
|
||||||
|
currentLockState = existingLock
|
||||||
|
currentOpacity = existingOpacity
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
SimpleNotesTheme {
|
||||||
|
NoteWidgetConfigScreen(
|
||||||
|
storage = storage,
|
||||||
|
initialLock = existingLock,
|
||||||
|
initialOpacity = existingOpacity,
|
||||||
|
selectedNoteId = existingNoteId,
|
||||||
|
onNoteSelected = { noteId, isLocked, opacity ->
|
||||||
|
configureWidget(noteId, isLocked, opacity)
|
||||||
|
},
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Save-FAB Callback
|
||||||
|
onSave = { noteId, isLocked, opacity ->
|
||||||
|
configureWidget(noteId, isLocked, opacity)
|
||||||
|
},
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Settings-Änderungen tracken für Auto-Save
|
||||||
|
onSettingsChanged = { noteId, isLocked, opacity ->
|
||||||
|
currentSelectedNoteId = noteId
|
||||||
|
currentLockState = isLocked
|
||||||
|
currentOpacity = opacity
|
||||||
|
},
|
||||||
|
onCancel = { finish() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureWidget(noteId: String, isLocked: Boolean, opacity: Float) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity)
|
||||||
|
.getGlanceIdBy(appWidgetId)
|
||||||
|
|
||||||
|
// Widget-State speichern
|
||||||
|
updateAppWidgetState(this@NoteWidgetConfigActivity, glanceId) { prefs ->
|
||||||
|
prefs[NoteWidgetState.KEY_NOTE_ID] = noteId
|
||||||
|
prefs[NoteWidgetState.KEY_IS_LOCKED] = isLocked
|
||||||
|
prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false
|
||||||
|
prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] = opacity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget initial rendern
|
||||||
|
NoteWidget().update(this@NoteWidgetConfigActivity, glanceId)
|
||||||
|
|
||||||
|
// Erfolg melden
|
||||||
|
val resultIntent = Intent().putExtra(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId
|
||||||
|
)
|
||||||
|
setResult(RESULT_OK, resultIntent)
|
||||||
|
|
||||||
|
// 🐛 FIX: Zurück zum Homescreen statt zur MainActivity
|
||||||
|
// moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar
|
||||||
|
if (!isTaskRoot) {
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
moveTaskToBack(true)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.List
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.outlined.Description
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Compose Screen für Widget-Konfiguration
|
||||||
|
*
|
||||||
|
* Zeigt alle Notizen als auswählbare Liste.
|
||||||
|
* Optionen: Widget-Lock, Hintergrund-Transparenz.
|
||||||
|
* Unterstützt Reconfiguration mit bestehenden Defaults.
|
||||||
|
*
|
||||||
|
* 🆕 v1.8.0 (IMPL_025): Save-FAB + onSettingsChanged für Reconfigure-Flow
|
||||||
|
*/
|
||||||
|
|
||||||
|
private const val NOTE_PREVIEW_MAX_LENGTH = 50
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NoteWidgetConfigScreen(
|
||||||
|
storage: NotesStorage,
|
||||||
|
initialLock: Boolean = false,
|
||||||
|
initialOpacity: Float = 1.0f,
|
||||||
|
selectedNoteId: String? = null,
|
||||||
|
onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit,
|
||||||
|
onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null,
|
||||||
|
onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null,
|
||||||
|
@Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use
|
||||||
|
) {
|
||||||
|
val allNotes = remember { storage.loadAllNotes().sortedByDescending { it.updatedAt } }
|
||||||
|
var lockWidget by remember { mutableStateOf(initialLock) }
|
||||||
|
var opacity by remember { mutableFloatStateOf(initialOpacity) }
|
||||||
|
var currentSelectedId by remember { mutableStateOf(selectedNoteId) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.widget_config_title)) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Save-FAB — sichtbar wenn eine Note ausgewählt ist
|
||||||
|
if (currentSelectedId != null) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
currentSelectedId?.let { noteId ->
|
||||||
|
onSave?.invoke(noteId, lockWidget, opacity)
|
||||||
|
?: onNoteSelected(noteId, lockWidget, opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = stringResource(R.string.widget_config_save)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
// Lock-Option
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_lock_label),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_lock_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = lockWidget,
|
||||||
|
onCheckedChange = {
|
||||||
|
lockWidget = it
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Settings-Änderung an Activity melden
|
||||||
|
onSettingsChanged?.invoke(currentSelectedId, lockWidget, opacity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opacity-Slider
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_opacity_label),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${(opacity * 100).roundToInt()}%",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_opacity_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Slider(
|
||||||
|
value = opacity,
|
||||||
|
onValueChange = {
|
||||||
|
opacity = it
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Settings-Änderung an Activity melden
|
||||||
|
onSettingsChanged?.invoke(currentSelectedId, lockWidget, opacity)
|
||||||
|
},
|
||||||
|
valueRange = 0f..1f,
|
||||||
|
steps = 9
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Hinweis
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_config_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notizen-Liste
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(allNotes, key = { it.id }) { note ->
|
||||||
|
NoteSelectionCard(
|
||||||
|
note = note,
|
||||||
|
isSelected = note.id == currentSelectedId,
|
||||||
|
onClick = {
|
||||||
|
currentSelectedId = note.id
|
||||||
|
// 🐛 FIX: Nur auswählen + Settings-Tracking, NICHT sofort konfigurieren
|
||||||
|
onSettingsChanged?.invoke(note.id, lockWidget, opacity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoteSelectionCard(
|
||||||
|
note: Note,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (note.noteType) {
|
||||||
|
NoteType.TEXT -> Icons.Outlined.Description
|
||||||
|
NoteType.CHECKLIST -> Icons.AutoMirrored.Outlined.List
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = note.title.ifEmpty { "Untitled" },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when (note.noteType) {
|
||||||
|
NoteType.TEXT -> note.content.take(NOTE_PREVIEW_MAX_LENGTH).replace("\n", " ")
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
val items = note.checklistItems ?: emptyList()
|
||||||
|
val checked = items.count { it.isChecked }
|
||||||
|
"✔ $checked/${items.size}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.Image
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalContext
|
||||||
|
import androidx.glance.LocalSize
|
||||||
|
import androidx.glance.action.actionParametersOf
|
||||||
|
import androidx.glance.action.actionStartActivity
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.CheckBox
|
||||||
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
|
import androidx.glance.appwidget.components.CircleIconButton
|
||||||
|
import androidx.glance.appwidget.components.TitleBar
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.color.ColorProvider
|
||||||
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Glance Composable Content für das Notiz-Widget
|
||||||
|
*
|
||||||
|
* Unterstützt fünf responsive Größenklassen (breit + schmal),
|
||||||
|
* NoteType-Icons, permanenten Options-Button, und einstellbare Opacity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Size Classification ──
|
||||||
|
|
||||||
|
private val WIDGET_HEIGHT_SMALL_THRESHOLD = 110.dp
|
||||||
|
private val WIDGET_SIZE_MEDIUM_THRESHOLD = 250.dp
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Increased preview lengths for better text visibility
|
||||||
|
private const val TEXT_PREVIEW_COMPACT_LENGTH = 120
|
||||||
|
private const val TEXT_PREVIEW_FULL_LENGTH = 300
|
||||||
|
|
||||||
|
private fun DpSize.toSizeClass(): WidgetSizeClass = when {
|
||||||
|
height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL
|
||||||
|
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_MED
|
||||||
|
width < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_TALL
|
||||||
|
height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_MED
|
||||||
|
else -> WidgetSizeClass.WIDE_TALL
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoteWidgetContent(
|
||||||
|
note: Note?,
|
||||||
|
isLocked: Boolean,
|
||||||
|
showOptions: Boolean,
|
||||||
|
bgOpacity: Float,
|
||||||
|
glanceId: GlanceId
|
||||||
|
) {
|
||||||
|
val size = LocalSize.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sizeClass = size.toSizeClass()
|
||||||
|
|
||||||
|
if (note == null) {
|
||||||
|
EmptyWidgetContent(bgOpacity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background mit Opacity
|
||||||
|
val bgModifier = if (bgOpacity < 1.0f) {
|
||||||
|
GlanceModifier.background(
|
||||||
|
ColorProvider(
|
||||||
|
day = Color.White.copy(alpha = bgOpacity),
|
||||||
|
night = Color(0xFF1C1B1F).copy(alpha = bgOpacity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GlanceModifier.background(GlanceTheme.colors.widgetBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.cornerRadius(16.dp)
|
||||||
|
.then(bgModifier)
|
||||||
|
) {
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
// 🆕 v1.8.0 (IMPL_025): Offizielle TitleBar mit CircleIconButton (48dp Hit Area)
|
||||||
|
TitleBar(
|
||||||
|
startIcon = ImageProvider(
|
||||||
|
when {
|
||||||
|
isLocked -> R.drawable.ic_lock
|
||||||
|
note.noteType == NoteType.CHECKLIST -> R.drawable.ic_widget_checklist
|
||||||
|
else -> R.drawable.ic_note
|
||||||
|
}
|
||||||
|
),
|
||||||
|
title = note.title.ifEmpty { "Untitled" },
|
||||||
|
iconColor = GlanceTheme.colors.onSurface,
|
||||||
|
textColor = GlanceTheme.colors.onSurface,
|
||||||
|
actions = {
|
||||||
|
CircleIconButton(
|
||||||
|
imageProvider = ImageProvider(R.drawable.ic_more_vert),
|
||||||
|
contentDescription = "Options",
|
||||||
|
backgroundColor = null, // Transparent → nur Icon + 48x48dp Hit Area
|
||||||
|
contentColor = GlanceTheme.colors.onSurface,
|
||||||
|
onClick = actionRunCallback<ShowOptionsAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optionsleiste (ein-/ausblendbar)
|
||||||
|
if (showOptions) {
|
||||||
|
OptionsBar(
|
||||||
|
isLocked = isLocked,
|
||||||
|
noteId = note.id,
|
||||||
|
glanceId = glanceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Bereich — Click öffnet Editor (unlocked) oder Options (locked)
|
||||||
|
val contentClickModifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
onClick = if (!isLocked) {
|
||||||
|
actionStartActivity(
|
||||||
|
ComponentName(context, ComposeNoteEditorActivity::class.java),
|
||||||
|
actionParametersOf(
|
||||||
|
androidx.glance.action.ActionParameters.Key<String>("extra_note_id") to note.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
actionRunCallback<ShowOptionsAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Content — abhängig von SizeClass
|
||||||
|
when (sizeClass) {
|
||||||
|
WidgetSizeClass.SMALL -> {
|
||||||
|
// Nur TitleBar, leerer Body als Click-Target
|
||||||
|
Box(modifier = contentClickModifier) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetSizeClass.NARROW_MED -> Box(modifier = contentClickModifier) {
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> TextNotePreview(note, compact = true)
|
||||||
|
NoteType.CHECKLIST -> ChecklistCompactView(
|
||||||
|
note = note,
|
||||||
|
maxItems = 2,
|
||||||
|
isLocked = isLocked,
|
||||||
|
glanceId = glanceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetSizeClass.NARROW_TALL -> Box(modifier = contentClickModifier) {
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> TextNoteFullView(note)
|
||||||
|
NoteType.CHECKLIST -> ChecklistFullView(
|
||||||
|
note = note,
|
||||||
|
isLocked = isLocked,
|
||||||
|
glanceId = glanceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetSizeClass.WIDE_MED -> Box(modifier = contentClickModifier) {
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> TextNotePreview(note, compact = false)
|
||||||
|
NoteType.CHECKLIST -> ChecklistCompactView(
|
||||||
|
note = note,
|
||||||
|
maxItems = 3,
|
||||||
|
isLocked = isLocked,
|
||||||
|
glanceId = glanceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetSizeClass.WIDE_TALL -> Box(modifier = contentClickModifier) {
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> TextNoteFullView(note)
|
||||||
|
NoteType.CHECKLIST -> ChecklistFullView(
|
||||||
|
note = note,
|
||||||
|
isLocked = isLocked,
|
||||||
|
glanceId = glanceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionsleiste — Lock/Unlock + Refresh + Open in App
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun OptionsBar(
|
||||||
|
isLocked: Boolean,
|
||||||
|
noteId: String,
|
||||||
|
glanceId: GlanceId
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
.background(GlanceTheme.colors.secondaryContainer),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Lock/Unlock Toggle
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(
|
||||||
|
if (isLocked) R.drawable.ic_lock_open else R.drawable.ic_lock
|
||||||
|
),
|
||||||
|
contentDescription = if (isLocked) "Unlock" else "Lock",
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = actionRunCallback<ToggleLockAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = GlanceModifier.width(4.dp))
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_refresh),
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = actionRunCallback<RefreshAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = GlanceModifier.width(4.dp))
|
||||||
|
|
||||||
|
// Settings (Reconfigure)
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_settings),
|
||||||
|
contentDescription = "Settings",
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = actionRunCallback<OpenConfigAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = GlanceModifier.width(4.dp))
|
||||||
|
|
||||||
|
// Open in App
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_open_in_new),
|
||||||
|
contentDescription = "Open",
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = actionStartActivity(
|
||||||
|
ComponentName(context, ComposeNoteEditorActivity::class.java),
|
||||||
|
actionParametersOf(
|
||||||
|
androidx.glance.action.ActionParameters.Key<String>("extra_note_id") to noteId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text Note Views ──
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextNotePreview(note: Note, compact: Boolean) {
|
||||||
|
Text(
|
||||||
|
text = note.content.take(
|
||||||
|
if (compact) TEXT_PREVIEW_COMPACT_LENGTH else TEXT_PREVIEW_FULL_LENGTH
|
||||||
|
),
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = if (compact) 13.sp else 14.sp
|
||||||
|
),
|
||||||
|
maxLines = if (compact) 3 else 5, // 🆕 v1.8.0: Increased for better preview
|
||||||
|
modifier = GlanceModifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextNoteFullView(note: Note) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
// 🆕 v1.8.0 Fix: Split text into individual lines instead of paragraphs.
|
||||||
|
// This ensures each line is a separate LazyColumn item that can scroll properly.
|
||||||
|
// Empty lines are preserved as small spacers for visual paragraph separation.
|
||||||
|
val lines = note.content.split("\n")
|
||||||
|
items(lines.size) { index ->
|
||||||
|
val line = lines[index]
|
||||||
|
if (line.isBlank()) {
|
||||||
|
// Preserve empty lines as spacing (paragraph separator)
|
||||||
|
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
maxLines = 5, // Allow wrapping but prevent single-item overflow
|
||||||
|
modifier = GlanceModifier.padding(bottom = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Checklist Views ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakte Checklist-Ansicht für MEDIUM-Größen.
|
||||||
|
* Zeigt maxItems interaktive Checkboxen + Zusammenfassung.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistCompactView(
|
||||||
|
note: Note,
|
||||||
|
maxItems: Int,
|
||||||
|
isLocked: Boolean,
|
||||||
|
glanceId: GlanceId
|
||||||
|
) {
|
||||||
|
val items = note.checklistItems?.sortedBy { it.order } ?: return
|
||||||
|
val visibleItems = items.take(maxItems)
|
||||||
|
val remainingCount = items.size - visibleItems.size
|
||||||
|
val checkedCount = items.count { it.isChecked }
|
||||||
|
|
||||||
|
Column(modifier = GlanceModifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
|
||||||
|
visibleItems.forEach { item ->
|
||||||
|
if (isLocked) {
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (item.isChecked) "✅" else "☐",
|
||||||
|
style = TextStyle(fontSize = 14.sp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = GlanceModifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = item.text,
|
||||||
|
style = TextStyle(
|
||||||
|
color = if (item.isChecked) GlanceTheme.colors.outline
|
||||||
|
else GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 13.sp
|
||||||
|
),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CheckBox(
|
||||||
|
checked = item.isChecked,
|
||||||
|
onCheckedChange = actionRunCallback<ToggleChecklistItemAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_NOTE_ID to note.id,
|
||||||
|
NoteWidgetActionKeys.KEY_ITEM_ID to item.id,
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
text = item.text,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 13.sp
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = "+$remainingCount more · ✔ $checkedCount/${items.size}",
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.outline,
|
||||||
|
fontSize = 12.sp
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier.padding(top = 2.dp, start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständige Checklist-Ansicht für LARGE-Größen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistFullView(
|
||||||
|
note: Note,
|
||||||
|
isLocked: Boolean,
|
||||||
|
glanceId: GlanceId
|
||||||
|
) {
|
||||||
|
val items = note.checklistItems?.sortedBy { it.order } ?: return
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
items(items.size) { index ->
|
||||||
|
val item = items[index]
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (item.isChecked) "✅" else "☐",
|
||||||
|
style = TextStyle(fontSize = 16.sp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = item.text,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CheckBox(
|
||||||
|
checked = item.isChecked,
|
||||||
|
onCheckedChange = actionRunCallback<ToggleChecklistItemAction>(
|
||||||
|
actionParametersOf(
|
||||||
|
NoteWidgetActionKeys.KEY_NOTE_ID to note.id,
|
||||||
|
NoteWidgetActionKeys.KEY_ITEM_ID to item.id,
|
||||||
|
NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
text = item.text,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty State ──
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyWidgetContent(bgOpacity: Float) {
|
||||||
|
val bgModifier = if (bgOpacity < 1.0f) {
|
||||||
|
GlanceModifier.background(
|
||||||
|
ColorProvider(
|
||||||
|
day = Color.White.copy(alpha = bgOpacity),
|
||||||
|
night = Color(0xFF1C1B1F).copy(alpha = bgOpacity)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GlanceModifier.background(GlanceTheme.colors.widgetBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.cornerRadius(16.dp)
|
||||||
|
.then(bgModifier)
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Note not found",
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.outline,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: BroadcastReceiver für das Notiz-Widget
|
||||||
|
*
|
||||||
|
* Muss im AndroidManifest.xml registriert werden.
|
||||||
|
* Delegiert alle Widget-Updates an NoteWidget.
|
||||||
|
*/
|
||||||
|
class NoteWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: NoteWidget = NoteWidget()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Widget-State Keys (per Widget-Instance)
|
||||||
|
*
|
||||||
|
* Gespeichert via PreferencesGlanceStateDefinition (DataStore).
|
||||||
|
* Jede Widget-Instanz hat eigene Preferences.
|
||||||
|
*/
|
||||||
|
object NoteWidgetState {
|
||||||
|
/** ID der angezeigten Notiz */
|
||||||
|
val KEY_NOTE_ID = stringPreferencesKey("widget_note_id")
|
||||||
|
|
||||||
|
/** Ob das Widget gesperrt ist (keine Bearbeitung möglich) */
|
||||||
|
val KEY_IS_LOCKED = booleanPreferencesKey("widget_is_locked")
|
||||||
|
|
||||||
|
/** Ob die Optionsleiste angezeigt wird */
|
||||||
|
val KEY_SHOW_OPTIONS = booleanPreferencesKey("widget_show_options")
|
||||||
|
|
||||||
|
/** Hintergrund-Transparenz (0.0 = vollständig transparent, 1.0 = opak) */
|
||||||
|
val KEY_BACKGROUND_OPACITY = floatPreferencesKey("widget_bg_opacity")
|
||||||
|
|
||||||
|
/** Timestamp des letzten Updates — erzwingt Widget-Recomposition */
|
||||||
|
val KEY_LAST_UPDATED = longPreferencesKey("widget_last_updated")
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.dettmer.simplenotes.widget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Size classification for responsive Note Widget layouts
|
||||||
|
*
|
||||||
|
* Determines which layout variant to use based on widget dimensions.
|
||||||
|
*/
|
||||||
|
enum class WidgetSizeClass {
|
||||||
|
SMALL, // Nur Titel
|
||||||
|
NARROW_MED, // Schmal, Vorschau
|
||||||
|
NARROW_TALL, // Schmal, voller Inhalt
|
||||||
|
WIDE_MED, // Breit, Vorschau
|
||||||
|
WIDE_TALL // Breit, voller Inhalt
|
||||||
|
}
|
||||||
9
android/app/src/main/res/drawable/ic_lock.xml
Normal file
9
android/app/src/main/res/drawable/ic_lock.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z"/>
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_lock_open.xml
Normal file
9
android/app/src/main/res/drawable/ic_lock_open.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z"/>
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_more_vert.xml
Normal file
9
android/app/src/main/res/drawable/ic_more_vert.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_note.xml
Normal file
9
android/app/src/main/res/drawable/ic_note.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/>
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_open_in_new.xml
Normal file
9
android/app/src/main/res/drawable/ic_open_in_new.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/>
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_refresh.xml
Normal file
9
android/app/src/main/res/drawable/ic_refresh.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_settings.xml
Normal file
9
android/app/src/main/res/drawable/ic_settings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="?android:attr/colorBackground" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Verbindungstyp"
|
android:text="@string/server_connection_type"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🏠 Intern (HTTP)"
|
android:text="@string/server_connection_http"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
android:checked="false" />
|
android:checked="false" />
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🌐 Extern (HTTPS)"
|
android:text="@string/server_connection_https"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
android:checked="true" />
|
android:checked="true" />
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
android:id="@+id/protocolHintText"
|
android:id="@+id/protocolHintText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
|
android:text="@string/server_connection_http_hint"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
@@ -104,12 +104,12 @@
|
|||||||
android:id="@+id/textInputLayoutServerUrl"
|
android:id="@+id/textInputLayoutServerUrl"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="Server-Adresse"
|
android:hint="@string/server_address"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
||||||
app:endIconMode="clear_text"
|
app:endIconMode="clear_text"
|
||||||
app:helperText="z.B. http://192.168.0.188:8080/notes"
|
app:helperText="@string/server_address_hint"
|
||||||
app:helperTextEnabled="true"
|
app:helperTextEnabled="true"
|
||||||
app:boxCornerRadiusTopStart="12dp"
|
app:boxCornerRadiusTopStart="12dp"
|
||||||
app:boxCornerRadiusTopEnd="12dp"
|
app:boxCornerRadiusTopEnd="12dp"
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Sync-Intervall"
|
android:text="@string/sync_interval_section"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
android:text="Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱️ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps."
|
android:text="@string/sync_interval_info"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:lineSpacingMultiplier="1.3" />
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
|
||||||
@@ -333,14 +333,14 @@
|
|||||||
android:id="@+id/radioInterval15"
|
android:id="@+id/radioInterval15"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="⚡ Alle 15 Minuten"
|
android:text="@string/sync_interval_15min_title"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
android:paddingVertical="8dp" />
|
android:paddingVertical="8dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)"
|
android:text="@string/sync_interval_15min_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOutline"
|
android:textColor="?attr/colorOutline"
|
||||||
android:paddingStart="48dp"
|
android:paddingStart="48dp"
|
||||||
@@ -351,14 +351,14 @@
|
|||||||
android:id="@+id/radioInterval30"
|
android:id="@+id/radioInterval30"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="✓ Alle 30 Minuten (Empfohlen)"
|
android:text="@string/sync_interval_30min_title"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
android:paddingVertical="8dp" />
|
android:paddingVertical="8dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)"
|
android:text="@string/sync_interval_30min_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOutline"
|
android:textColor="?attr/colorOutline"
|
||||||
android:paddingStart="48dp"
|
android:paddingStart="48dp"
|
||||||
@@ -369,14 +369,14 @@
|
|||||||
android:id="@+id/radioInterval60"
|
android:id="@+id/radioInterval60"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🔋 Alle 60 Minuten"
|
android:text="@string/sync_interval_60min_title"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
android:paddingVertical="8dp" />
|
android:paddingVertical="8dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)"
|
android:text="@string/sync_interval_60min_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOutline"
|
android:textColor="?attr/colorOutline"
|
||||||
android:paddingStart="48dp" />
|
android:paddingStart="48dp" />
|
||||||
@@ -405,7 +405,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Markdown Desktop-Integration"
|
android:text="@string/settings_markdown"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
android:text="ℹ️ Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
|
android:text="@string/markdown_info"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnPrimaryContainer"
|
android:textColor="?attr/colorOnPrimaryContainer"
|
||||||
android:lineSpacingMultiplier="1.3" />
|
android:lineSpacingMultiplier="1.3" />
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🔄 Markdown Auto-Sync"
|
android:text="@string/markdown_auto_sync_title"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
<androidx.appcompat.widget.SwitchCompat
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
|
android:text="@string/markdown_auto_sync_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:text="Oder synchronisiere Markdown-Dateien manuell:"
|
android:text="@string/settings_markdown_manual_hint"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
android:textColor="?attr/colorOnSurface"
|
android:textColor="?attr/colorOnSurface"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
android:id="@+id/buttonManualMarkdownSync"
|
android:id="@+id/buttonManualMarkdownSync"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Markdown synchronisieren"
|
android:text="@string/settings_markdown_manual_button"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
android:text="ℹ️ Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
|
android:text="@string/settings_backup_info"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnPrimaryContainer"
|
android:textColor="?attr/colorOnPrimaryContainer"
|
||||||
android:lineSpacingMultiplier="1.3" />
|
android:lineSpacingMultiplier="1.3" />
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Lokales Backup"
|
android:text="@string/settings_backup_local_title"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@
|
|||||||
android:id="@+id/buttonCreateBackup"
|
android:id="@+id/buttonCreateBackup"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📥 Backup erstellen"
|
android:text="@string/backup_create"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
android:id="@+id/buttonRestoreFromFile"
|
android:id="@+id/buttonRestoreFromFile"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📤 Aus Datei wiederherstellen"
|
android:text="@string/backup_restore_file"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
@@ -566,7 +566,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Server-Backup"
|
android:text="@string/settings_backup_server_title"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
@@ -575,7 +575,7 @@
|
|||||||
android:id="@+id/buttonRestoreFromServer"
|
android:id="@+id/buttonRestoreFromServer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🔄 Vom Server wiederherstellen"
|
android:text="@string/backup_restore_server"
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -600,7 +600,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Über diese App"
|
android:text="@string/settings_about"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:layout_marginBottom="16dp" />
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📱 App-Version"
|
android:text="@string/settings_about_app_version"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:textColor="?attr/colorPrimary"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
@@ -631,7 +631,7 @@
|
|||||||
android:id="@+id/textViewAppVersion"
|
android:id="@+id/textViewAppVersion"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Version wird geladen..."
|
android:text="@string/settings_about_app_version_loading"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -659,7 +659,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🌐 GitHub Repository"
|
android:text="@string/settings_about_github"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:textColor="?attr/colorPrimary"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
@@ -667,7 +667,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Quellcode, Issues & Dokumentation"
|
android:text="@string/about_github_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -695,7 +695,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="👤 Entwickler"
|
android:text="@string/settings_about_developer"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:textColor="?attr/colorPrimary"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
@@ -703,7 +703,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="GitHub Profil: @inventory69"
|
android:text="@string/about_developer_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -730,7 +730,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="⚖️ Lizenz"
|
android:text="@string/settings_about_license"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:textColor="?attr/colorPrimary"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
@@ -738,7 +738,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="MIT License - Open Source"
|
android:text="@string/about_license_subtitle"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -767,7 +767,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Debug & Diagnose"
|
android:text="@string/settings_debug"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:layout_marginBottom="16dp" />
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📝 Datei-Logging"
|
android:text="@string/settings_debug_file_logging"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:textColor="?attr/colorPrimary"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
@@ -804,7 +804,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Sync-Logs in Datei speichern"
|
android:text="@string/settings_debug_file_logging_desc"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -834,7 +834,7 @@
|
|||||||
android:id="@+id/buttonExportLogs"
|
android:id="@+id/buttonExportLogs"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📤 Logs exportieren & teilen"
|
android:text="@string/settings_debug_export_logs"
|
||||||
style="@style/Widget.Material3.Button.TonalButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
@@ -843,7 +843,7 @@
|
|||||||
android:id="@+id/buttonClearLogs"
|
android:id="@+id/buttonClearLogs"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🗑️ Logs löschen"
|
android:text="@string/settings_debug_delete_logs"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
58
android/app/src/main/res/layout/widget_preview.xml
Normal file
58
android/app/src/main/res/layout/widget_preview.xml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Widget Preview Layout for Android 12+ widget picker (previewLayout) -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:background="@drawable/widget_preview_background"
|
||||||
|
android:clipToOutline="true">
|
||||||
|
|
||||||
|
<!-- TitleBar Simulation -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:src="@drawable/ic_note_24"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/widget_preview_title"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:src="@drawable/ic_more_vert"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:alpha="0.5" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Content Preview -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/widget_preview_content"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?android:attr/textColorSecondary"
|
||||||
|
android:maxLines="5"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -58,6 +58,60 @@
|
|||||||
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
|
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
|
||||||
<string name="sync_already_running">Synchronisierung läuft bereits</string>
|
<string name="sync_already_running">Synchronisierung läuft bereits</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: SyncStatus enum values -->
|
||||||
|
<string name="sync_status_synced">Mit Server synchronisiert</string>
|
||||||
|
<string name="sync_status_pending">Warte auf Synchronisierung</string>
|
||||||
|
<string name="sync_status_conflict">Synchronisierungskonflikt erkannt</string>
|
||||||
|
<string name="sync_status_local_only">Noch nicht synchronisiert</string>
|
||||||
|
<string name="sync_status_deleted_on_server">Auf Server gelöscht</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: Sync-Status Legende Dialog -->
|
||||||
|
<string name="sync_legend_button">Sync-Status Hilfe</string>
|
||||||
|
<string name="sync_legend_title">Sync-Status Icons</string>
|
||||||
|
<string name="sync_legend_description">Jede Notiz zeigt ein kleines Icon, das den Sync-Status anzeigt:</string>
|
||||||
|
<string name="sync_legend_synced_label">Synchronisiert</string>
|
||||||
|
<string name="sync_legend_synced_desc">Diese Notiz ist auf allen Geräten aktuell.</string>
|
||||||
|
<string name="sync_legend_pending_label">Ausstehend</string>
|
||||||
|
<string name="sync_legend_pending_desc">Diese Notiz hat lokale Änderungen, die noch synchronisiert werden müssen.</string>
|
||||||
|
<string name="sync_legend_conflict_label">Konflikt</string>
|
||||||
|
<string name="sync_legend_conflict_desc">Diese Notiz wurde auf mehreren Geräten gleichzeitig geändert. Die neueste Version wurde beibehalten.</string>
|
||||||
|
<string name="sync_legend_local_only_label">Nur lokal</string>
|
||||||
|
<string name="sync_legend_local_only_desc">Diese Notiz wurde noch nie mit dem Server synchronisiert.</string>
|
||||||
|
<string name="sync_legend_deleted_label">Auf Server gelöscht</string>
|
||||||
|
<string name="sync_legend_deleted_desc">Diese Notiz wurde auf einem anderen Gerät oder direkt auf dem Server gelöscht. Sie existiert noch lokal.</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0 (IMPL_022): Sync-Banner Löschungsanzahl -->
|
||||||
|
<string name="sync_deleted_on_server_count">%d auf Server gelöscht</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0 (IMPL_006): Sync-Phasen Strings -->
|
||||||
|
<string name="sync_phase_preparing">Synchronisiere…</string>
|
||||||
|
<string name="sync_phase_checking">Prüfe Server…</string>
|
||||||
|
<string name="sync_phase_uploading">Hochladen…</string>
|
||||||
|
<string name="sync_phase_downloading">Herunterladen…</string>
|
||||||
|
<string name="sync_phase_importing_markdown">Markdown importieren…</string>
|
||||||
|
<string name="sync_phase_saving">Speichern…</string>
|
||||||
|
<string name="sync_phase_completed">Sync abgeschlossen</string>
|
||||||
|
<string name="sync_phase_error">Sync fehlgeschlagen</string>
|
||||||
|
|
||||||
|
<!-- 🔀 v1.8.0 (IMPL_020): Sortierung: Notizliste -->
|
||||||
|
<string name="sort_notes">Notizen sortieren</string>
|
||||||
|
<string name="sort_ascending">Aufsteigend</string>
|
||||||
|
<string name="sort_descending">Absteigend</string>
|
||||||
|
<string name="sort_by_updated">Zuletzt bearbeitet</string>
|
||||||
|
<string name="sort_by_created">Erstelldatum</string>
|
||||||
|
<string name="sort_by_name">Name</string>
|
||||||
|
<string name="sort_by_type">Typ</string>
|
||||||
|
<string name="close">Schließen</string>
|
||||||
|
|
||||||
|
<!-- 🔀 v1.8.0 (IMPL_020): Sortierung: Checkliste -->
|
||||||
|
<string name="sort_checklist">Checkliste sortieren</string>
|
||||||
|
<string name="sort_checklist_manual">Manuell</string>
|
||||||
|
<string name="sort_checklist_alpha_asc">A → Z</string>
|
||||||
|
<string name="sort_checklist_alpha_desc">Z → A</string>
|
||||||
|
<string name="sort_checklist_unchecked_first">Unerledigte zuerst</string>
|
||||||
|
<string name="sort_checklist_checked_first">Erledigte zuerst</string>
|
||||||
|
<string name="apply">Anwenden</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- DELETE DIALOGS -->
|
<!-- DELETE DIALOGS -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -162,12 +216,26 @@
|
|||||||
<string name="settings_markdown">Markdown Desktop-Integration</string>
|
<string name="settings_markdown">Markdown Desktop-Integration</string>
|
||||||
<string name="settings_markdown_auto_on">Auto-Sync: An</string>
|
<string name="settings_markdown_auto_on">Auto-Sync: An</string>
|
||||||
<string name="settings_markdown_auto_off">Auto-Sync: Aus</string>
|
<string name="settings_markdown_auto_off">Auto-Sync: Aus</string>
|
||||||
|
<string name="settings_markdown_manual_hint">Oder synchronisiere Markdown-Dateien manuell:</string>
|
||||||
|
<string name="settings_markdown_manual_button">Markdown synchronisieren</string>
|
||||||
<string name="settings_backup">Backup & Wiederherstellung</string>
|
<string name="settings_backup">Backup & Wiederherstellung</string>
|
||||||
<string name="settings_backup_subtitle">Lokales oder Server-Backup</string>
|
<string name="settings_backup_subtitle">Lokales oder Server-Backup</string>
|
||||||
|
<string name="settings_backup_info">📦 Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt.</string>
|
||||||
|
<string name="settings_backup_local_title">Lokales Backup</string>
|
||||||
|
<string name="settings_backup_server_title">Server-Backup</string>
|
||||||
<string name="settings_about">Über diese App</string>
|
<string name="settings_about">Über diese App</string>
|
||||||
|
<string name="settings_about_app_version">📱 App-Version</string>
|
||||||
|
<string name="settings_about_app_version_loading">Version wird geladen…</string>
|
||||||
|
<string name="settings_about_github">🌐 GitHub Repository</string>
|
||||||
|
<string name="settings_about_developer">👤 Entwickler</string>
|
||||||
|
<string name="settings_about_license">⚖️ Lizenz</string>
|
||||||
<string name="settings_debug">Debug & Diagnose</string>
|
<string name="settings_debug">Debug & Diagnose</string>
|
||||||
<string name="settings_debug_logging_on">Logging: An</string>
|
<string name="settings_debug_logging_on">Logging: An</string>
|
||||||
<string name="settings_debug_logging_off">Logging: Aus</string>
|
<string name="settings_debug_logging_off">Logging: Aus</string>
|
||||||
|
<string name="settings_debug_file_logging">📝 Datei-Logging</string>
|
||||||
|
<string name="settings_debug_file_logging_desc">Sync-Logs in Datei speichern</string>
|
||||||
|
<string name="settings_debug_export_logs">📤 Logs exportieren & teilen</string>
|
||||||
|
<string name="settings_debug_delete_logs">🗑️ Logs löschen</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- SETTINGS - SERVER -->
|
<!-- SETTINGS - SERVER -->
|
||||||
@@ -223,7 +291,12 @@
|
|||||||
<string name="sync_section_background">📡 Hintergrund-Sync</string>
|
<string name="sync_section_background">📡 Hintergrund-Sync</string>
|
||||||
<string name="sync_section_advanced">⚙️ Erweitert</string>
|
<string name="sync_section_advanced">⚙️ Erweitert</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: Sync Settings Restructure -->
|
||||||
|
<string name="sync_section_triggers">Sync-Auslöser</string>
|
||||||
|
<string name="sync_section_network_performance">Netzwerk & Performance</string>
|
||||||
|
|
||||||
<string name="sync_wifi_only_hint">💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird.</string>
|
<string name="sync_wifi_only_hint">💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird.</string>
|
||||||
|
<string name="sync_wifi_only_error">Sync funktioniert nur wenn WLAN verbunden ist</string>
|
||||||
|
|
||||||
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
|
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
|
||||||
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
|
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
|
||||||
@@ -330,6 +403,10 @@
|
|||||||
<string name="debug_delete_logs">🗑️ Logs löschen</string>
|
<string name="debug_delete_logs">🗑️ Logs löschen</string>
|
||||||
<string name="debug_delete_logs_title">Logs löschen?</string>
|
<string name="debug_delete_logs_title">Logs löschen?</string>
|
||||||
<string name="debug_delete_logs_message">Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.</string>
|
<string name="debug_delete_logs_message">Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.</string>
|
||||||
|
<string name="debug_test_section">Test-Modus</string>
|
||||||
|
<string name="debug_reset_changelog">Changelog-Dialog zurücksetzen</string>
|
||||||
|
<string name="debug_reset_changelog_desc">Changelog beim nächsten App-Start anzeigen</string>
|
||||||
|
<string name="debug_changelog_reset">Changelog wird beim nächsten Start angezeigt</string>
|
||||||
<!-- Legacy -->
|
<!-- Legacy -->
|
||||||
<string name="file_logging_privacy_notice">ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
|
<string name="file_logging_privacy_notice">ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
|
||||||
|
|
||||||
@@ -340,7 +417,7 @@
|
|||||||
<string name="language_system_default">Systemstandard</string>
|
<string name="language_system_default">Systemstandard</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
<string name="language_german">Deutsch</string>
|
<string name="language_german">Deutsch</string>
|
||||||
<string name="language_info">ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
|
<string name="language_info">ℹ️ Wähle deine bevorzugte Sprache. Die Ansicht wird kurz aktualisiert, um die Änderung anzuwenden.</string>
|
||||||
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
|
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -350,7 +427,7 @@
|
|||||||
<string name="display_mode_title">Notizen-Ansicht</string>
|
<string name="display_mode_title">Notizen-Ansicht</string>
|
||||||
<string name="display_mode_list">📋 Listen-Ansicht</string>
|
<string name="display_mode_list">📋 Listen-Ansicht</string>
|
||||||
<string name="display_mode_grid">🎨 Raster-Ansicht</string>
|
<string name="display_mode_grid">🎨 Raster-Ansicht</string>
|
||||||
<string name="display_mode_info">Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein.</string>
|
<string name="display_mode_info">Die Raster-Ansicht zeigt Notizen in zwei Spalten. Alle Notizen erscheinen nebeneinander in einer kompakten Übersicht.</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- SETTINGS - ABOUT -->
|
<!-- SETTINGS - ABOUT -->
|
||||||
@@ -365,6 +442,30 @@
|
|||||||
<string name="about_developer_subtitle">GitHub Profil: @inventory69</string>
|
<string name="about_developer_subtitle">GitHub Profil: @inventory69</string>
|
||||||
<string name="about_license_title">Lizenz</string>
|
<string name="about_license_title">Lizenz</string>
|
||||||
<string name="about_license_subtitle">MIT License - Open Source</string>
|
<string name="about_license_subtitle">MIT License - Open Source</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Changelog Link in About -->
|
||||||
|
<string name="about_changelog_title">Changelog</string>
|
||||||
|
<string name="about_changelog_subtitle">Vollständige Versionshistorie</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Post-Update Changelog Dialog -->
|
||||||
|
<string name="update_changelog_title">Was ist neu</string>
|
||||||
|
<string name="update_changelog_dismiss">Alles klar!</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Backup Progress -->
|
||||||
|
<string name="backup_progress_creating">Backup wird erstellt…</string>
|
||||||
|
<string name="backup_progress_restoring">Backup wird wiederhergestellt…</string>
|
||||||
|
<string name="backup_progress_restoring_server">Notizen werden vom Server heruntergeladen…</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Backup Progress - Completion -->
|
||||||
|
<string name="backup_progress_complete">Backup erstellt!</string>
|
||||||
|
<string name="restore_progress_complete">Wiederherstellung abgeschlossen!</string>
|
||||||
|
<string name="restore_server_progress_complete">Download abgeschlossen!</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Backup Progress - Error -->
|
||||||
|
<string name="backup_progress_failed">Backup fehlgeschlagen</string>
|
||||||
|
<string name="restore_progress_failed">Wiederherstellung fehlgeschlagen</string>
|
||||||
|
<string name="restore_server_progress_failed">Download fehlgeschlagen</string>
|
||||||
|
|
||||||
<string name="about_privacy_title">🔒 Datenschutz</string>
|
<string name="about_privacy_title">🔒 Datenschutz</string>
|
||||||
<string name="about_privacy_text">Diese App sammelt keine Daten. Alle Notizen werden nur lokal auf deinem Gerät und auf deinem eigenen WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung.</string>
|
<string name="about_privacy_text">Diese App sammelt keine Daten. Alle Notizen werden nur lokal auf deinem Gerät und auf deinem eigenen WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung.</string>
|
||||||
|
|
||||||
@@ -476,4 +577,37 @@
|
|||||||
<item quantity="one">%d Notiz synchronisiert</item>
|
<item quantity="one">%d Notiz synchronisiert</item>
|
||||||
<item quantity="other">%d Notizen synchronisiert</item>
|
<item quantity="other">%d Notizen synchronisiert</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<!-- v1.8.0 (IMPL_017): Checklist separator -->
|
||||||
|
<plurals name="checked_items_count">
|
||||||
|
<item quantity="one">%d erledigt</item>
|
||||||
|
<item quantity="other">%d erledigt</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- PARALLEL DOWNLOADS v1.8.0 -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="sync_parallel_downloads_title">Parallele Downloads</string>
|
||||||
|
<string name="sync_parallel_downloads_unit">parallel</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_1">Sequentiell (langsam, sicher)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_3">Ausgewogen (3x schneller)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_5">Empfohlen (5x schneller)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_7">Schnell (7x schneller)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_10">Maximum (10x schneller, kann Server belasten)</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- WIDGETS v1.8.0 -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="widget_name">Simple Notes</string>
|
||||||
|
<string name="widget_description">Zeige eine Notiz oder Checkliste auf dem Startbildschirm</string>
|
||||||
|
<string name="widget_config_title">Notiz auswählen</string>
|
||||||
|
<string name="widget_config_hint">Tippe auf eine Notiz, um sie als Widget hinzuzufügen</string>
|
||||||
|
<string name="widget_config_save">Speichern</string>
|
||||||
|
<string name="widget_lock_label">Widget sperren</string>
|
||||||
|
<string name="widget_lock_description">Versehentliches Bearbeiten verhindern</string>
|
||||||
|
<string name="widget_note_not_found">Notiz nicht gefunden</string>
|
||||||
|
<string name="widget_opacity_label">Hintergrund-Transparenz</string>
|
||||||
|
<string name="widget_opacity_description">Transparenz des Widget-Hintergrunds anpassen</string>
|
||||||
|
<string name="widget_preview_title">Einkaufsliste</string>
|
||||||
|
<string name="widget_preview_content">Milch, Eier, Brot, Butter, Käse, Tomaten, Nudeln, Reis, Olivenöl…</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -58,6 +58,60 @@
|
|||||||
<string name="sync_status_error">Sync failed</string>
|
<string name="sync_status_error">Sync failed</string>
|
||||||
<string name="sync_already_running">Sync already in progress</string>
|
<string name="sync_already_running">Sync already in progress</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: SyncStatus enum values -->
|
||||||
|
<string name="sync_status_synced">Synced with server</string>
|
||||||
|
<string name="sync_status_pending">Waiting for sync</string>
|
||||||
|
<string name="sync_status_conflict">Sync conflict detected</string>
|
||||||
|
<string name="sync_status_local_only">Not yet synced</string>
|
||||||
|
<string name="sync_status_deleted_on_server">Deleted on server</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: Sync Status Legend Dialog -->
|
||||||
|
<string name="sync_legend_button">Sync status help</string>
|
||||||
|
<string name="sync_legend_title">Sync Status Icons</string>
|
||||||
|
<string name="sync_legend_description">Each note shows a small icon indicating its sync status:</string>
|
||||||
|
<string name="sync_legend_synced_label">Synced</string>
|
||||||
|
<string name="sync_legend_synced_desc">This note is up to date on all devices.</string>
|
||||||
|
<string name="sync_legend_pending_label">Pending</string>
|
||||||
|
<string name="sync_legend_pending_desc">This note has local changes waiting to be synced.</string>
|
||||||
|
<string name="sync_legend_conflict_label">Conflict</string>
|
||||||
|
<string name="sync_legend_conflict_desc">This note was changed on multiple devices simultaneously. The latest version was kept.</string>
|
||||||
|
<string name="sync_legend_local_only_label">Local only</string>
|
||||||
|
<string name="sync_legend_local_only_desc">This note has never been synced to the server yet.</string>
|
||||||
|
<string name="sync_legend_deleted_label">Deleted on server</string>
|
||||||
|
<string name="sync_legend_deleted_desc">This note was deleted on another device or directly on the server. It still exists locally.</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0 (IMPL_022): Sync banner deletion count -->
|
||||||
|
<string name="sync_deleted_on_server_count">%d deleted on server</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0 (IMPL_006): Sync phase strings -->
|
||||||
|
<string name="sync_phase_preparing">Synchronizing…</string>
|
||||||
|
<string name="sync_phase_checking">Checking server…</string>
|
||||||
|
<string name="sync_phase_uploading">Uploading…</string>
|
||||||
|
<string name="sync_phase_downloading">Downloading…</string>
|
||||||
|
|
||||||
|
<!-- 🔀 v1.8.0 (IMPL_020): Sort: Note List -->
|
||||||
|
<string name="sort_notes">Sort notes</string>
|
||||||
|
<string name="sort_ascending">Ascending</string>
|
||||||
|
<string name="sort_descending">Descending</string>
|
||||||
|
<string name="sort_by_updated">Last modified</string>
|
||||||
|
<string name="sort_by_created">Date created</string>
|
||||||
|
<string name="sort_by_name">Name</string>
|
||||||
|
<string name="sort_by_type">Type</string>
|
||||||
|
<string name="close">Close</string>
|
||||||
|
|
||||||
|
<!-- 🔀 v1.8.0 (IMPL_020): Sort: Checklist -->
|
||||||
|
<string name="sort_checklist">Sort checklist</string>
|
||||||
|
<string name="sort_checklist_manual">Manual</string>
|
||||||
|
<string name="sort_checklist_alpha_asc">A → Z</string>
|
||||||
|
<string name="sort_checklist_alpha_desc">Z → A</string>
|
||||||
|
<string name="sort_checklist_unchecked_first">Unchecked first</string>
|
||||||
|
<string name="sort_checklist_checked_first">Checked first</string>
|
||||||
|
<string name="apply">Apply</string>
|
||||||
|
<string name="sync_phase_importing_markdown">Importing Markdown…</string>
|
||||||
|
<string name="sync_phase_saving">Saving…</string>
|
||||||
|
<string name="sync_phase_completed">Sync complete</string>
|
||||||
|
<string name="sync_phase_error">Sync failed</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- DELETE DIALOGS -->
|
<!-- DELETE DIALOGS -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -162,12 +216,26 @@
|
|||||||
<string name="settings_markdown">Markdown Desktop Integration</string>
|
<string name="settings_markdown">Markdown Desktop Integration</string>
|
||||||
<string name="settings_markdown_auto_on">Auto-Sync: On</string>
|
<string name="settings_markdown_auto_on">Auto-Sync: On</string>
|
||||||
<string name="settings_markdown_auto_off">Auto-Sync: Off</string>
|
<string name="settings_markdown_auto_off">Auto-Sync: Off</string>
|
||||||
|
<string name="settings_markdown_manual_hint">Or sync markdown files manually:</string>
|
||||||
|
<string name="settings_markdown_manual_button">Sync Markdown</string>
|
||||||
<string name="settings_backup">Backup & Restore</string>
|
<string name="settings_backup">Backup & Restore</string>
|
||||||
<string name="settings_backup_subtitle">Local or server backup</string>
|
<string name="settings_backup_subtitle">Local or server backup</string>
|
||||||
|
<string name="settings_backup_info">📦 A safety backup is automatically created before each restore.</string>
|
||||||
|
<string name="settings_backup_local_title">Local Backup</string>
|
||||||
|
<string name="settings_backup_server_title">Server Backup</string>
|
||||||
<string name="settings_about">About this App</string>
|
<string name="settings_about">About this App</string>
|
||||||
|
<string name="settings_about_app_version">📱 App Version</string>
|
||||||
|
<string name="settings_about_app_version_loading">Loading version…</string>
|
||||||
|
<string name="settings_about_github">🌐 GitHub Repository</string>
|
||||||
|
<string name="settings_about_developer">👤 Developer</string>
|
||||||
|
<string name="settings_about_license">⚖️ License</string>
|
||||||
<string name="settings_debug">Debug & Diagnostics</string>
|
<string name="settings_debug">Debug & Diagnostics</string>
|
||||||
<string name="settings_debug_logging_on">Logging: On</string>
|
<string name="settings_debug_logging_on">Logging: On</string>
|
||||||
<string name="settings_debug_logging_off">Logging: Off</string>
|
<string name="settings_debug_logging_off">Logging: Off</string>
|
||||||
|
<string name="settings_debug_file_logging">📝 File Logging</string>
|
||||||
|
<string name="settings_debug_file_logging_desc">Save sync logs to file</string>
|
||||||
|
<string name="settings_debug_export_logs">📤 Export & share logs</string>
|
||||||
|
<string name="settings_debug_delete_logs">🗑️ Delete logs</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- SETTINGS - SERVER -->
|
<!-- SETTINGS - SERVER -->
|
||||||
@@ -223,7 +291,12 @@
|
|||||||
<string name="sync_section_background">📡 Background Sync</string>
|
<string name="sync_section_background">📡 Background Sync</string>
|
||||||
<string name="sync_section_advanced">⚙️ Advanced</string>
|
<string name="sync_section_advanced">⚙️ Advanced</string>
|
||||||
|
|
||||||
|
<!-- 🆕 v1.8.0: Sync Settings Restructure -->
|
||||||
|
<string name="sync_section_triggers">Sync Triggers</string>
|
||||||
|
<string name="sync_section_network_performance">Network & Performance</string>
|
||||||
|
|
||||||
<string name="sync_wifi_only_hint">💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected.</string>
|
<string name="sync_wifi_only_hint">💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected.</string>
|
||||||
|
<string name="sync_wifi_only_error">Sync only works when WiFi is connected</string>
|
||||||
|
|
||||||
<string name="sync_trigger_on_save_title">After Saving</string>
|
<string name="sync_trigger_on_save_title">After Saving</string>
|
||||||
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
|
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
|
||||||
@@ -330,6 +403,10 @@
|
|||||||
<string name="debug_delete_logs">🗑️ Delete Logs</string>
|
<string name="debug_delete_logs">🗑️ Delete Logs</string>
|
||||||
<string name="debug_delete_logs_title">Delete logs?</string>
|
<string name="debug_delete_logs_title">Delete logs?</string>
|
||||||
<string name="debug_delete_logs_message">All saved sync logs will be permanently deleted.</string>
|
<string name="debug_delete_logs_message">All saved sync logs will be permanently deleted.</string>
|
||||||
|
<string name="debug_test_section">Test Mode</string>
|
||||||
|
<string name="debug_reset_changelog">Reset Changelog Dialog</string>
|
||||||
|
<string name="debug_reset_changelog_desc">Show changelog on next app start</string>
|
||||||
|
<string name="debug_changelog_reset">Changelog will show on next start</string>
|
||||||
<!-- Legacy -->
|
<!-- Legacy -->
|
||||||
<string name="file_logging_privacy_notice">ℹ️ Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
|
<string name="file_logging_privacy_notice">ℹ️ Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time.</string>
|
||||||
|
|
||||||
@@ -340,7 +417,7 @@
|
|||||||
<string name="language_system_default">System Default</string>
|
<string name="language_system_default">System Default</string>
|
||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
<string name="language_german">Deutsch</string>
|
<string name="language_german">Deutsch</string>
|
||||||
<string name="language_info">ℹ️ Choose your preferred language. The app will restart to apply the change.</string>
|
<string name="language_info">ℹ️ Choose your preferred language. The view will briefly refresh to apply the change.</string>
|
||||||
<string name="language_changed_restart">Language changed. Restarting…</string>
|
<string name="language_changed_restart">Language changed. Restarting…</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -350,7 +427,7 @@
|
|||||||
<string name="display_mode_title">Note Display Mode</string>
|
<string name="display_mode_title">Note Display Mode</string>
|
||||||
<string name="display_mode_list">📋 List View</string>
|
<string name="display_mode_list">📋 List View</string>
|
||||||
<string name="display_mode_grid">🎨 Grid View</string>
|
<string name="display_mode_grid">🎨 Grid View</string>
|
||||||
<string name="display_mode_info">Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width.</string>
|
<string name="display_mode_info">Grid view shows notes in a two-column layout. All notes appear side-by-side in a clean, compact overview.</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- SETTINGS - ABOUT -->
|
<!-- SETTINGS - ABOUT -->
|
||||||
@@ -365,6 +442,30 @@
|
|||||||
<string name="about_developer_subtitle">GitHub Profile: @inventory69</string>
|
<string name="about_developer_subtitle">GitHub Profile: @inventory69</string>
|
||||||
<string name="about_license_title">License</string>
|
<string name="about_license_title">License</string>
|
||||||
<string name="about_license_subtitle">MIT License - Open Source</string>
|
<string name="about_license_subtitle">MIT License - Open Source</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Changelog Link in About -->
|
||||||
|
<string name="about_changelog_title">Changelog</string>
|
||||||
|
<string name="about_changelog_subtitle">Full version history</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Post-Update Changelog Dialog -->
|
||||||
|
<string name="update_changelog_title">What\'s New</string>
|
||||||
|
<string name="update_changelog_dismiss">Got it!</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Backup Progress -->
|
||||||
|
<string name="backup_progress_creating">Creating backup…</string>
|
||||||
|
<string name="backup_progress_restoring">Restoring backup…</string>
|
||||||
|
<string name="backup_progress_restoring_server">Downloading notes from server…</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Backup Progress - Completion -->
|
||||||
|
<string name="backup_progress_complete">Backup created!</string>
|
||||||
|
<string name="restore_progress_complete">Restore complete!</string>
|
||||||
|
<string name="restore_server_progress_complete">Download complete!</string>
|
||||||
|
|
||||||
|
<!-- v1.8.0: Backup Progress - Error -->
|
||||||
|
<string name="backup_progress_failed">Backup failed</string>
|
||||||
|
<string name="restore_progress_failed">Restore failed</string>
|
||||||
|
<string name="restore_server_progress_failed">Download failed</string>
|
||||||
|
|
||||||
<string name="about_privacy_title">🔒 Privacy</string>
|
<string name="about_privacy_title">🔒 Privacy</string>
|
||||||
<string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string>
|
<string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string>
|
||||||
|
|
||||||
@@ -476,4 +577,38 @@
|
|||||||
<item quantity="one">%d note synced</item>
|
<item quantity="one">%d note synced</item>
|
||||||
<item quantity="other">%d notes synced</item>
|
<item quantity="other">%d notes synced</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<!-- v1.8.0 (IMPL_017): Checklist separator -->
|
||||||
|
<plurals name="checked_items_count">
|
||||||
|
<item quantity="one">%d completed</item>
|
||||||
|
<item quantity="other">%d completed</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- PARALLEL DOWNLOADS v1.8.0 -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="sync_parallel_downloads_title">Parallel Downloads</string>
|
||||||
|
<string name="sync_parallel_downloads_unit">parallel</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_1">Sequential (slowest, safest)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_3">Balanced (3x faster)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_5">Recommended (5x faster)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_7">Fast (7x faster)</string>
|
||||||
|
<string name="sync_parallel_downloads_desc_10">Maximum (10x faster, may stress server)</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- WIDGETS v1.8.0 -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="widget_name">Simple Notes</string>
|
||||||
|
<string name="widget_description">Display a note or checklist on your home screen</string>
|
||||||
|
<string name="widget_config_title">Choose a Note</string>
|
||||||
|
<string name="widget_config_hint">Tap a note to add it as a widget</string>
|
||||||
|
<string name="widget_config_save">Save</string>
|
||||||
|
<string name="widget_lock_label">Lock widget</string>
|
||||||
|
<string name="widget_lock_description">Prevent accidental edits</string>
|
||||||
|
<string name="widget_note_not_found">Note not found</string>
|
||||||
|
<string name="widget_opacity_label">Background opacity</string>
|
||||||
|
<string name="widget_opacity_description">Adjust the transparency of the widget background</string>
|
||||||
|
<string name="widget_preview_title">Shopping List</string>
|
||||||
|
<string name="widget_preview_content">Milk, eggs, bread, butter, cheese, tomatoes, pasta, rice, olive oil…</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Allow HTTP for all connections during development/testing -->
|
<!-- Allow HTTP for local network connections (192.168.x.x, 10.x.x.x, etc.)
|
||||||
<!-- Production validation happens in UrlValidator.kt to restrict HTTP to:
|
|
||||||
|
⚠️ Security Note:
|
||||||
|
We intentionally allow cleartext traffic because this app is designed for
|
||||||
|
self-hosted WebDAV servers, which often run on local networks via HTTP.
|
||||||
|
|
||||||
|
HTTP connections are restricted at the application level by UrlValidator.kt to:
|
||||||
- Private IP ranges: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.x.x.x
|
- Private IP ranges: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.x.x.x
|
||||||
- .local domains (mDNS/Bonjour)
|
- .local domains (mDNS/Bonjour)
|
||||||
|
|
||||||
This permissive config is necessary because Android's Network Security Config
|
Android's Network Security Config doesn't support IP-based domain rules,
|
||||||
doesn't support IP-based rules, only domain patterns.
|
so we must allow cleartext globally but validate URLs in the app.
|
||||||
We handle security through application-level validation instead. -->
|
Public servers MUST use HTTPS. -->
|
||||||
<base-config cleartextTrafficPermitted="true">
|
<base-config cleartextTrafficPermitted="true"
|
||||||
|
tools:ignore="InsecureBaseConfiguration">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
<!-- 🔐 v1.7.0: Trust user-installed CA certificates for self-signed SSL support -->
|
|
||||||
<certificates src="user" />
|
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
|
|
||||||
|
<!-- Allow user-installed CA certificates only in debug builds for testing
|
||||||
|
self-signed certificates during development -->
|
||||||
|
<debug-overrides>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</debug-overrides>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
20
android/app/src/main/res/xml/note_widget_info.xml
Normal file
20
android/app/src/main/res/xml/note_widget_info.xml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 🆕 v1.8.0: Widget metadata for homescreen note/checklist widget -->
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/widget_description"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="180dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:minResizeWidth="110dp"
|
||||||
|
android:minResizeHeight="80dp"
|
||||||
|
android:maxResizeWidth="530dp"
|
||||||
|
android:maxResizeHeight="530dp"
|
||||||
|
android:targetCellWidth="3"
|
||||||
|
android:targetCellHeight="2"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="dev.dettmer.simplenotes.widget.NoteWidgetConfigActivity"
|
||||||
|
android:previewImage="@layout/widget_preview"
|
||||||
|
android:previewLayout="@layout/widget_preview"
|
||||||
|
android:widgetFeatures="reconfigurable" />
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 v1.7.2: Basic validation tests for v1.7.2 bugfixes
|
||||||
|
*
|
||||||
|
* This test file validates that the critical bugfixes are working:
|
||||||
|
* - IMPL_001: Deletion Tracker Race Condition
|
||||||
|
* - IMPL_002: ISO8601 Timezone Parsing
|
||||||
|
* - IMPL_003: SafeSardine Memory Leak (Closeable)
|
||||||
|
* - IMPL_004: E-Tag Batch Caching
|
||||||
|
* - IMPL_014: JSON/Markdown Timestamp Sync
|
||||||
|
* - IMPL_015: SyncStatus PENDING Fix
|
||||||
|
*/
|
||||||
|
class BugfixValidationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_015 - Note toJson contains all fields`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test-123",
|
||||||
|
title = "Test Note",
|
||||||
|
content = "Content",
|
||||||
|
deviceId = "device-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = note.toJson()
|
||||||
|
|
||||||
|
// Verify JSON contains critical fields
|
||||||
|
assertTrue("JSON should contain id", json.contains("\"id\""))
|
||||||
|
assertTrue("JSON should contain title", json.contains("\"title\""))
|
||||||
|
assertTrue("JSON should contain deviceId", json.contains("\"deviceId\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_015 - Note copy preserves all fields`() {
|
||||||
|
val original = Note(
|
||||||
|
id = "original-123",
|
||||||
|
title = "Original",
|
||||||
|
content = "Content",
|
||||||
|
deviceId = "device-1",
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
val copied = original.copy(syncStatus = SyncStatus.SYNCED)
|
||||||
|
|
||||||
|
// Verify copy worked correctly
|
||||||
|
assertEquals("original-123", copied.id)
|
||||||
|
assertEquals("Original", copied.title)
|
||||||
|
assertEquals(SyncStatus.SYNCED, copied.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_014 - fromMarkdown accepts serverModifiedTime parameter`() {
|
||||||
|
val markdown = """
|
||||||
|
---
|
||||||
|
id: test-456
|
||||||
|
title: Test
|
||||||
|
created: 2026-01-01T10:00:00Z
|
||||||
|
updated: 2026-01-01T11:00:00Z
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# Test
|
||||||
|
|
||||||
|
Content
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val serverMtime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// This should not crash - parameter is optional
|
||||||
|
val note1 = Note.fromMarkdown(markdown)
|
||||||
|
assertNotNull(note1)
|
||||||
|
|
||||||
|
val note2 = Note.fromMarkdown(markdown, serverModifiedTime = serverMtime)
|
||||||
|
assertNotNull(note2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_002 - fromMarkdown handles various timestamp formats`() {
|
||||||
|
// UTC format with Z
|
||||||
|
val markdown1 = """
|
||||||
|
---
|
||||||
|
id: test-utc
|
||||||
|
title: UTC Test
|
||||||
|
created: 2026-02-04T12:30:45Z
|
||||||
|
updated: 2026-02-04T12:30:45Z
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# UTC Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val note1 = Note.fromMarkdown(markdown1)
|
||||||
|
assertNotNull("Should parse UTC format", note1)
|
||||||
|
|
||||||
|
// Format with timezone offset
|
||||||
|
val markdown2 = """
|
||||||
|
---
|
||||||
|
id: test-tz
|
||||||
|
title: Timezone Test
|
||||||
|
created: 2026-02-04T13:30:45+01:00
|
||||||
|
updated: 2026-02-04T13:30:45+01:00
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# Timezone Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val note2 = Note.fromMarkdown(markdown2)
|
||||||
|
assertNotNull("Should parse timezone offset format", note2)
|
||||||
|
|
||||||
|
// Format without timezone (should be treated as UTC)
|
||||||
|
val markdown3 = """
|
||||||
|
---
|
||||||
|
id: test-no-tz
|
||||||
|
title: No TZ Test
|
||||||
|
created: 2026-02-04T12:30:45
|
||||||
|
updated: 2026-02-04T12:30:45
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# No TZ Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val note3 = Note.fromMarkdown(markdown3)
|
||||||
|
assertNotNull("Should parse format without timezone", note3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Note data class has all required fields`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "field-test",
|
||||||
|
title = "Field Test",
|
||||||
|
content = "Content",
|
||||||
|
deviceId = "device-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify all critical fields exist
|
||||||
|
assertNotNull(note.id)
|
||||||
|
assertNotNull(note.title)
|
||||||
|
assertNotNull(note.content)
|
||||||
|
assertNotNull(note.deviceId)
|
||||||
|
assertNotNull(note.noteType)
|
||||||
|
assertNotNull(note.syncStatus)
|
||||||
|
assertNotNull(note.createdAt)
|
||||||
|
assertNotNull(note.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SyncStatus enum has all required values`() {
|
||||||
|
// Verify all sync states exist
|
||||||
|
assertNotNull(SyncStatus.PENDING)
|
||||||
|
assertNotNull(SyncStatus.SYNCED)
|
||||||
|
assertNotNull(SyncStatus.LOCAL_ONLY)
|
||||||
|
assertNotNull(SyncStatus.CONFLICT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Note toJson and fromJson roundtrip works`() {
|
||||||
|
val original = Note(
|
||||||
|
id = "roundtrip-123",
|
||||||
|
title = "Roundtrip Test",
|
||||||
|
content = "Test Content",
|
||||||
|
deviceId = "device-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = original.toJson()
|
||||||
|
val restored = Note.fromJson(json)
|
||||||
|
|
||||||
|
assertNotNull(restored)
|
||||||
|
assertEquals(original.id, restored!!.id)
|
||||||
|
assertEquals(original.title, restored.title)
|
||||||
|
assertEquals(original.content, restored.content)
|
||||||
|
assertEquals(original.deviceId, restored.deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package dev.dettmer.simplenotes.sync.parallel
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Unit tests for IMPL_005 - Parallel Downloads
|
||||||
|
*
|
||||||
|
* These tests validate the basic functionality of parallel downloads:
|
||||||
|
* - DownloadTask data class creation
|
||||||
|
* - DownloadTaskResult sealed class variants
|
||||||
|
* - ParallelDownloader constants
|
||||||
|
*
|
||||||
|
* Note: Full integration tests with mocked Sardine would require MockK/Mockito,
|
||||||
|
* which are not currently in the project dependencies.
|
||||||
|
*/
|
||||||
|
class ParallelDownloadTest {
|
||||||
|
|
||||||
|
// Note: DownloadTask tests require mocking DavResource, skipping for now
|
||||||
|
// Full integration tests would require MockK or Mockito
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DownloadTaskResult Success contains correct data`() {
|
||||||
|
val result = DownloadTaskResult.Success(
|
||||||
|
noteId = "note-1",
|
||||||
|
content = "{\"id\":\"note-1\"}",
|
||||||
|
etag = "etag123"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("note-1", result.noteId)
|
||||||
|
assertEquals("{\"id\":\"note-1\"}", result.content)
|
||||||
|
assertEquals("etag123", result.etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DownloadTaskResult Failure contains error`() {
|
||||||
|
val error = Exception("Network error")
|
||||||
|
val result = DownloadTaskResult.Failure(
|
||||||
|
noteId = "note-2",
|
||||||
|
error = error
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("note-2", result.noteId)
|
||||||
|
assertEquals("Network error", result.error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DownloadTaskResult Skipped contains reason`() {
|
||||||
|
val result = DownloadTaskResult.Skipped(
|
||||||
|
noteId = "note-3",
|
||||||
|
reason = "Already up to date"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("note-3", result.noteId)
|
||||||
|
assertEquals("Already up to date", result.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ParallelDownloader has correct default constants`() {
|
||||||
|
assertEquals(5, ParallelDownloader.DEFAULT_MAX_PARALLEL)
|
||||||
|
assertEquals(2, ParallelDownloader.DEFAULT_RETRY_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ParallelDownloader constants are in valid range`() {
|
||||||
|
// Verify default values are within our configured range
|
||||||
|
assertTrue(
|
||||||
|
"Default parallel downloads should be >= 1",
|
||||||
|
ParallelDownloader.DEFAULT_MAX_PARALLEL >= 1
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"Default parallel downloads should be <= 10",
|
||||||
|
ParallelDownloader.DEFAULT_MAX_PARALLEL <= 10
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"Default retry count should be >= 0",
|
||||||
|
ParallelDownloader.DEFAULT_RETRY_COUNT >= 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DownloadTaskResult types are distinguishable`() {
|
||||||
|
val success: DownloadTaskResult = DownloadTaskResult.Success("id1", "content", "etag")
|
||||||
|
val failure: DownloadTaskResult = DownloadTaskResult.Failure("id2", Exception())
|
||||||
|
val skipped: DownloadTaskResult = DownloadTaskResult.Skipped("id3", "reason")
|
||||||
|
|
||||||
|
assertTrue("Success should be instance of Success", success is DownloadTaskResult.Success)
|
||||||
|
assertTrue("Failure should be instance of Failure", failure is DownloadTaskResult.Failure)
|
||||||
|
assertTrue("Skipped should be instance of Skipped", skipped is DownloadTaskResult.Skipped)
|
||||||
|
|
||||||
|
assertFalse("Success should not be Failure", success is DownloadTaskResult.Failure)
|
||||||
|
assertFalse("Failure should not be Skipped", failure is DownloadTaskResult.Skipped)
|
||||||
|
assertFalse("Skipped should not be Success", skipped is DownloadTaskResult.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DownloadTaskResult when expression works correctly`() {
|
||||||
|
val results = listOf(
|
||||||
|
DownloadTaskResult.Success("id1", "content", "etag"),
|
||||||
|
DownloadTaskResult.Failure("id2", Exception("error")),
|
||||||
|
DownloadTaskResult.Skipped("id3", "reason")
|
||||||
|
)
|
||||||
|
|
||||||
|
val types = results.map { result ->
|
||||||
|
when (result) {
|
||||||
|
is DownloadTaskResult.Success -> "success"
|
||||||
|
is DownloadTaskResult.Failure -> "failure"
|
||||||
|
is DownloadTaskResult.Skipped -> "skipped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(listOf("success", "failure", "skipped"), types)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0 (IMPL_017): Unit Tests für Checklisten-Sortierung
|
||||||
|
*
|
||||||
|
* Validiert die Auto-Sort Funktionalität:
|
||||||
|
* - Unchecked items erscheinen vor checked items
|
||||||
|
* - Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten (stabile Sortierung)
|
||||||
|
* - Order-Werte werden korrekt neu zugewiesen
|
||||||
|
*/
|
||||||
|
class ChecklistSortingTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a test ChecklistItemState
|
||||||
|
*/
|
||||||
|
private fun item(id: String, checked: Boolean, order: Int): ChecklistItemState {
|
||||||
|
return ChecklistItemState(
|
||||||
|
id = id,
|
||||||
|
text = "Item $id",
|
||||||
|
isChecked = checked,
|
||||||
|
order = order
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the sortChecklistItems() function from NoteEditorViewModel
|
||||||
|
* (Since it's private, we test the logic here)
|
||||||
|
*/
|
||||||
|
private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
|
||||||
|
val unchecked = items.filter { !it.isChecked }
|
||||||
|
val checked = items.filter { it.isChecked }
|
||||||
|
|
||||||
|
return (unchecked + checked).mapIndexed { index, item ->
|
||||||
|
item.copy(order = index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unchecked items appear before checked items`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("a", checked = true, order = 0),
|
||||||
|
item("b", checked = false, order = 1),
|
||||||
|
item("c", checked = true, order = 2),
|
||||||
|
item("d", checked = false, order = 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
assertFalse("First item should be unchecked", sorted[0].isChecked) // b
|
||||||
|
assertFalse("Second item should be unchecked", sorted[1].isChecked) // d
|
||||||
|
assertTrue("Third item should be checked", sorted[2].isChecked) // a
|
||||||
|
assertTrue("Fourth item should be checked", sorted[3].isChecked) // c
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `relative order within groups is preserved (stable sort)`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("first-checked", checked = true, order = 0),
|
||||||
|
item("first-unchecked", checked = false, order = 1),
|
||||||
|
item("second-checked", checked = true, order = 2),
|
||||||
|
item("second-unchecked",checked = false, order = 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
assertEquals("first-unchecked", sorted[0].id)
|
||||||
|
assertEquals("second-unchecked", sorted[1].id)
|
||||||
|
assertEquals("first-checked", sorted[2].id)
|
||||||
|
assertEquals("second-checked", sorted[3].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all unchecked - no change needed`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("a", checked = false, order = 0),
|
||||||
|
item("b", checked = false, order = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
assertEquals("a", sorted[0].id)
|
||||||
|
assertEquals("b", sorted[1].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all checked - no change needed`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("a", checked = true, order = 0),
|
||||||
|
item("b", checked = true, order = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
assertEquals("a", sorted[0].id)
|
||||||
|
assertEquals("b", sorted[1].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `order values are reassigned after sort`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("a", checked = true, order = 0),
|
||||||
|
item("b", checked = false, order = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
assertEquals(0, sorted[0].order) // b → order 0
|
||||||
|
assertEquals(1, sorted[1].order) // a → order 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty list returns empty list`() {
|
||||||
|
val items = emptyList<ChecklistItemState>()
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
assertTrue("Empty list should remain empty", sorted.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single item list returns unchanged`() {
|
||||||
|
val items = listOf(item("a", checked = false, order = 0))
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
assertEquals(1, sorted.size)
|
||||||
|
assertEquals("a", sorted[0].id)
|
||||||
|
assertEquals(0, sorted[0].order)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mixed list with multiple items maintains correct grouping`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("1", checked = false, order = 0),
|
||||||
|
item("2", checked = true, order = 1),
|
||||||
|
item("3", checked = false, order = 2),
|
||||||
|
item("4", checked = true, order = 3),
|
||||||
|
item("5", checked = false, order = 4)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
// First 3 should be unchecked
|
||||||
|
assertFalse(sorted[0].isChecked)
|
||||||
|
assertFalse(sorted[1].isChecked)
|
||||||
|
assertFalse(sorted[2].isChecked)
|
||||||
|
|
||||||
|
// Last 2 should be checked
|
||||||
|
assertTrue(sorted[3].isChecked)
|
||||||
|
assertTrue(sorted[4].isChecked)
|
||||||
|
|
||||||
|
// Verify order within unchecked group (1, 3, 5)
|
||||||
|
assertEquals("1", sorted[0].id)
|
||||||
|
assertEquals("3", sorted[1].id)
|
||||||
|
assertEquals("5", sorted[2].id)
|
||||||
|
|
||||||
|
// Verify order within checked group (2, 4)
|
||||||
|
assertEquals("2", sorted[3].id)
|
||||||
|
assertEquals("4", sorted[4].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `orders are sequential after sorting`() {
|
||||||
|
val items = listOf(
|
||||||
|
item("a", checked = true, order = 10),
|
||||||
|
item("b", checked = false, order = 5),
|
||||||
|
item("c", checked = false, order = 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
val sorted = sortChecklistItems(items)
|
||||||
|
|
||||||
|
// Orders should be 0, 1, 2 regardless of input
|
||||||
|
assertEquals(0, sorted[0].order)
|
||||||
|
assertEquals(1, sorted[1].order)
|
||||||
|
assertEquals(2, sorted[2].order)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
• Behoben: App-Absturz auf Android 9 (Issue #15) - Danke an @roughnecks
|
• Behoben: App-Absturz auf Android 9 - Thanks to @roughnecks
|
||||||
- WorkManager Expedited Work Kompatibilität (getForegroundInfo)
|
• Behoben: Deutsche Texte trotz englischer App-Sprache
|
||||||
- Kernel-VPN-Kompatibilität (Wireguard tun/wg Interfaces)
|
• Verbessert: Sync-Verbindungsstabilität
|
||||||
• Verbessert: Stabilität und Verbindungsverwaltung
|
• Verbessert: Code-Qualität und Zuverlässigkeit
|
||||||
• Technisch: Optimierter HTTP-Connection-Lebenszyklus
|
|
||||||
|
|
||||||
|
|||||||
8
fastlane/metadata/android/de-DE/changelogs/19.txt
Normal file
8
fastlane/metadata/android/de-DE/changelogs/19.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
📝 KRITISCHE BUG FIXES & EDITOR-VORBEREITUNG
|
||||||
|
|
||||||
|
• Verbessert: Auto-Aktualisierung der Zeitstempel in UI (alle 30s)
|
||||||
|
• Behoben: Änderungen von externen Editoren nicht synchronisiert
|
||||||
|
• Behoben: Server-JSON zeigt immer "PENDING" Status
|
||||||
|
• Behoben: Deletion Tracker Race Condition bei Batch-Löschungen
|
||||||
|
• Behoben: ISO8601 Timezone Parsing (+01:00, -05:00)
|
||||||
|
• Verbessert: E-Tag Batch Caching Performance (~50-100ms schneller)
|
||||||
11
fastlane/metadata/android/de-DE/changelogs/20.txt
Normal file
11
fastlane/metadata/android/de-DE/changelogs/20.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
🎉 v1.8.0 — WIDGETS & UI-VERBESSERUNGEN
|
||||||
|
|
||||||
|
• Neu: Homescreen-Widgets mit interaktiven Checkboxen
|
||||||
|
• Neu: Widget-Transparenz & Sperr-Einstellungen
|
||||||
|
• Neu: Notiz-Sortierung (Datum, Titel, Typ)
|
||||||
|
• Neu: Parallele Downloads (1-10 gleichzeitig)
|
||||||
|
• Verbessert: Raster-Standard, Sync-Struktur, Live-Fortschritt
|
||||||
|
• Weitere UI/UX-Verbesserungen
|
||||||
|
|
||||||
|
Vollständiger Changelog:
|
||||||
|
https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.de.md
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
• Fixed: App crash on Android 9 (Issue #15) - Thanks to @roughnecks
|
• Fixed: App crash on Android 9 - Thanks to @roughnecks
|
||||||
- WorkManager expedited work compatibility (getForegroundInfo)
|
• Fixed: German text appearing despite English language setting
|
||||||
- Kernel-VPN compatibility (Wireguard tun/wg interfaces)
|
• Improved: Sync connection stability (longer timeout)
|
||||||
• Improved: Stability and connection management
|
• Improved: Code quality and reliability
|
||||||
• Technical: Optimized HTTP connection lifecycle
|
|
||||||
|
|||||||
8
fastlane/metadata/android/en-US/changelogs/19.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/19.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
📝 CRITICAL BUG FIXES & EDITOR PREPARATION
|
||||||
|
|
||||||
|
• Improved: Auto-updating timestamps in UI (every 30s)
|
||||||
|
• Fixed: External editor changes not synced
|
||||||
|
• Fixed: Server JSON always showing "PENDING" status
|
||||||
|
• Fixed: Deletion tracker race condition in batch deletes
|
||||||
|
• Fixed: ISO8601 timezone parsing (+01:00, -05:00)
|
||||||
|
• Improved: E-Tag batch caching performance (~50-100ms faster)
|
||||||
13
fastlane/metadata/android/en-US/changelogs/20.txt
Normal file
13
fastlane/metadata/android/en-US/changelogs/20.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
🎉 v1.8.0 — WIDGETS & UI POLISH
|
||||||
|
|
||||||
|
• New: Home screen widgets with interactive checkboxes
|
||||||
|
• New: Widget opacity & lock settings
|
||||||
|
• New: Note sorting (date, title, type)
|
||||||
|
• New: Parallel downloads (1-10 simultaneous)
|
||||||
|
• Improved: Grid view as default layout
|
||||||
|
• Improved: Sync settings reorganized into clear sections
|
||||||
|
• Improved: Live sync progress with status indicators
|
||||||
|
• More UI/UX improvements
|
||||||
|
|
||||||
|
Full changelog:
|
||||||
|
https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md
|
||||||
Reference in New Issue
Block a user