Compare commits
20 Commits
v1.8.0
...
2-Migrate-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ddad7e5e7 | |||
|
|
5764e8c0ec | ||
|
|
b1e7f1750e | ||
|
|
da371436cd | ||
|
|
cf54f44377 | ||
|
|
bc3f137669 | ||
|
|
1a6617a4ec | ||
|
|
27e6b9d4ac | ||
|
|
3e4b1bd07e | ||
|
|
66d98c0cad | ||
|
|
c72b3fe1c0 | ||
|
|
a1a574a725 | ||
|
|
7dbc06d102 | ||
|
|
2c43b47e96 | ||
|
|
ffe0e46e3d | ||
|
|
fe6935a6b7 | ||
|
|
7b558113cf | ||
|
|
24fe32a973 | ||
|
|
b5a3a3c096 | ||
|
|
63561737e1 |
4
.github/workflows/build-production-apk.yml
vendored
@@ -61,11 +61,11 @@ jobs:
|
||||
run: |
|
||||
mkdir -p apk-output
|
||||
|
||||
# Standard Flavor - Universal APK
|
||||
# Standard Flavor
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
|
||||
|
||||
# F-Droid Flavor - Universal APK
|
||||
# F-Droid Flavor
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||
|
||||
|
||||
6
.github/workflows/pr-build-check.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Gradle Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -69,14 +69,14 @@ jobs:
|
||||
continue-on-error: true
|
||||
- name: Build-Ergebnis pruefen
|
||||
run: |
|
||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-debug.apk" ]; then
|
||||
echo "✅ Standard Debug APK erfolgreich gebaut"
|
||||
ls -lh android/app/build/outputs/apk/standard/debug/*.apk
|
||||
else
|
||||
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" ]; then
|
||||
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
||||
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||
else
|
||||
|
||||
@@ -8,6 +8,83 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [1.8.1] - 2026-02-11
|
||||
|
||||
### 🛠️ Bugfix & Polish Release
|
||||
|
||||
Checklisten-Fixes, Widget-Verbesserungen, Sync-Härtung und Code-Qualität.
|
||||
|
||||
### 🐛 Fehlerbehebungen
|
||||
|
||||
**Checklisten-Sortierung Persistenz** ([7dbc06d](https://github.com/inventory69/simple-notes-sync/commit/7dbc06d))
|
||||
- Sortier-Option wurde beim erneuten Öffnen einer Checkliste nicht angewendet
|
||||
- Ursache: `sortChecklistItems()` sortierte immer unchecked-first statt `_lastChecklistSortOption` zu lesen
|
||||
- Alle Sortier-Modi werden nun korrekt wiederhergestellt (Manuell, Alphabetisch, Unchecked/Checked First)
|
||||
|
||||
**Widget-Scroll bei Standard-Größe** ([c72b3fe](https://github.com/inventory69/simple-notes-sync/commit/c72b3fe))
|
||||
- Scrollen funktionierte nicht bei Standard-3×2-Widget-Größe (110–150dp Höhe)
|
||||
- Neue Größenklassen `NARROW_SCROLL` und `WIDE_SCROLL` mit 150dp-Schwelle
|
||||
- `clickable`-Modifier bei entsperrten Checklisten entfernt, um Scrollen zu ermöglichen
|
||||
|
||||
**Auto-Sync Toast entfernt** ([fe6935a](https://github.com/inventory69/simple-notes-sync/commit/fe6935a))
|
||||
- Unerwartete Toast-Benachrichtigung bei automatischem Hintergrund-Sync entfernt
|
||||
- Stiller Auto-Sync bleibt still; nur Fehler werden angezeigt
|
||||
|
||||
**Gradient- & Drag-Regression** ([24fe32a](https://github.com/inventory69/simple-notes-sync/commit/24fe32a))
|
||||
- Gradient-Overlay-Regression bei langen Checklisten-Items behoben
|
||||
- Drag-and-Drop-Flackern beim Verschieben zwischen Bereichen behoben
|
||||
|
||||
### 🆕 Neue Funktionen
|
||||
|
||||
**Widget-Checklisten: Sortierung & Trennlinien** ([66d98c0](https://github.com/inventory69/simple-notes-sync/commit/66d98c0))
|
||||
- Widgets übernehmen die gespeicherte Sortier-Option aus dem Editor
|
||||
- Visuelle Trennlinie zwischen unerledigten/erledigten Items (MANUAL & UNCHECKED_FIRST)
|
||||
- Auto-Sortierung beim Abhaken von Checkboxen im Widget
|
||||
- Emoji-Änderung: ✅ → ☑️ für erledigte Items
|
||||
|
||||
**Checklisten-Vorschau-Sortierung** ([2c43b47](https://github.com/inventory69/simple-notes-sync/commit/2c43b47))
|
||||
- Hauptbildschirm-Vorschau (NoteCard, NoteCardCompact, NoteCardGrid) zeigt gespeicherte Sortierung
|
||||
- Neuer `ChecklistPreviewHelper` mit geteilter Sortier-Logik
|
||||
|
||||
**Auto-Scroll bei Zeilenumbruch** ([3e4b1bd](https://github.com/inventory69/simple-notes-sync/commit/3e4b1bd))
|
||||
- Checklisten-Editor scrollt automatisch wenn Text in eine neue Zeile umbricht
|
||||
- Cursor bleibt am unteren Rand sichtbar während der Eingabe
|
||||
|
||||
**Separator Drag Cross-Boundary** ([7b55811](https://github.com/inventory69/simple-notes-sync/commit/7b55811))
|
||||
- Drag-and-Drop funktioniert nun über die Checked/Unchecked-Trennlinie hinweg
|
||||
- Items wechseln automatisch ihren Status beim Verschieben über die Grenze
|
||||
- Extrahiertes `DraggableChecklistItem`-Composable für Wiederverwendbarkeit
|
||||
|
||||
### 🔄 Verbesserungen
|
||||
|
||||
**Sync-Ratenlimit & Akkuschutz** ([ffe0e46](https://github.com/inventory69/simple-notes-sync/commit/ffe0e46), [a1a574a](https://github.com/inventory69/simple-notes-sync/commit/a1a574a))
|
||||
- Globaler 30-Sekunden-Cooldown zwischen Sync-Operationen (Auto/WiFi/Periodisch)
|
||||
- onSave-Syncs umgehen den globalen Cooldown (behalten eigenen 5s-Throttle)
|
||||
- Neuer `SyncStateManager`-Singleton für zentrales State-Tracking
|
||||
- Verhindert Akkuverbrauch durch schnelle aufeinanderfolgende Syncs
|
||||
|
||||
**Toast → Banner-Migration** ([27e6b9d](https://github.com/inventory69/simple-notes-sync/commit/27e6b9d))
|
||||
- Alle nicht-interaktiven Benachrichtigungen auf einheitliches Banner-System migriert
|
||||
- Server-Lösch-Ergebnisse als INFO/ERROR-Banner angezeigt
|
||||
- INFO-Phase zu SyncPhase-Enum mit Auto-Hide (2,5s) hinzugefügt
|
||||
- Snackbars mit Undo-Aktionen bleiben unverändert
|
||||
|
||||
**ProGuard-Regeln Audit** ([6356173](https://github.com/inventory69/simple-notes-sync/commit/6356173))
|
||||
- Fehlende Keep-Regeln für Widget-ActionCallback-Klassen hinzugefügt
|
||||
- Compose-spezifische ProGuard-Regeln hinzugefügt
|
||||
- Verhindert ClassNotFoundException in Release-Builds
|
||||
|
||||
### 🧹 Code-Qualität
|
||||
|
||||
**Detekt-Compliance** ([1a6617a](https://github.com/inventory69/simple-notes-sync/commit/1a6617a))
|
||||
- Alle 12 Detekt-Findings behoben (0 Issues verbleibend)
|
||||
- `NoteEditorViewModel.loadNote()` refactored um Verschachtelungstiefe zu reduzieren
|
||||
- Konstanten für Magic Numbers im Editor extrahiert
|
||||
- Unbenutzte Imports aus `UpdateChangelogSheet` entfernt
|
||||
- `maxIssues: 0` in Detekt-Konfiguration gesetzt
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-02-10
|
||||
|
||||
### 🚨 CRITICAL BUGFIX (Tag neu erstellt)
|
||||
@@ -891,6 +968,21 @@ Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist j
|
||||
|
||||
---
|
||||
|
||||
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
|
||||
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
|
||||
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
|
||||
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
|
||||
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
|
||||
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
|
||||
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
|
||||
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
|
||||
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
|
||||
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
|
||||
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
|
||||
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
|
||||
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
|
||||
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||
|
||||
92
CHANGELOG.md
@@ -8,6 +8,83 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [1.8.1] - 2026-02-11
|
||||
|
||||
### 🛠️ Bugfix & Polish Release
|
||||
|
||||
Checklist fixes, widget improvements, sync hardening, and code quality cleanup.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
**Checklist Sort Persistence** ([7dbc06d](https://github.com/inventory69/simple-notes-sync/commit/7dbc06d))
|
||||
- Fixed sort option not applied when reopening a checklist
|
||||
- Root cause: `sortChecklistItems()` always sorted unchecked-first instead of reading `_lastChecklistSortOption`
|
||||
- Now correctly restores all sort modes (Manual, Alphabetical, Unchecked/Checked First)
|
||||
|
||||
**Widget Scroll on Standard Size** ([c72b3fe](https://github.com/inventory69/simple-notes-sync/commit/c72b3fe))
|
||||
- Fixed scroll not working on standard 3×2 widget size (110–150dp height)
|
||||
- Added `NARROW_SCROLL` and `WIDE_SCROLL` size classes with 150dp threshold
|
||||
- Removed `clickable` modifier from unlocked checklists to enable scrolling
|
||||
|
||||
**Auto-Sync Toast Removed** ([fe6935a](https://github.com/inventory69/simple-notes-sync/commit/fe6935a))
|
||||
- Removed unexpected toast notification on automatic background sync
|
||||
- Silent auto-sync stays silent; only errors are shown
|
||||
|
||||
**Gradient & Drag Regression** ([24fe32a](https://github.com/inventory69/simple-notes-sync/commit/24fe32a))
|
||||
- Fixed gradient overlay regression on long checklist items
|
||||
- Fixed drag-and-drop flicker when moving items between boundaries
|
||||
|
||||
### 🆕 New Features
|
||||
|
||||
**Widget Checklist Sorting & Separators** ([66d98c0](https://github.com/inventory69/simple-notes-sync/commit/66d98c0))
|
||||
- Widgets now apply saved sort option from the editor
|
||||
- Visual separator between unchecked/checked items (MANUAL & UNCHECKED_FIRST modes)
|
||||
- Auto-sort when toggling checkboxes in the widget
|
||||
- Changed ✅ → ☑️ emoji for checked items
|
||||
|
||||
**Checklist Preview Sorting** ([2c43b47](https://github.com/inventory69/simple-notes-sync/commit/2c43b47))
|
||||
- Main screen preview (NoteCard, NoteCardCompact, NoteCardGrid) now respects saved sort option
|
||||
- New `ChecklistPreviewHelper` with shared sorting logic
|
||||
|
||||
**Auto-Scroll on Line Wrap** ([3e4b1bd](https://github.com/inventory69/simple-notes-sync/commit/3e4b1bd))
|
||||
- Checklist editor auto-scrolls when typing causes text to wrap to a new line
|
||||
- Keeps cursor visible at bottom of list during editing
|
||||
|
||||
**Separator Drag Cross-Boundary** ([7b55811](https://github.com/inventory69/simple-notes-sync/commit/7b55811))
|
||||
- Drag-and-drop now works across the checked/unchecked separator
|
||||
- Items auto-toggle their checked state when dragged across boundaries
|
||||
- Extracted `DraggableChecklistItem` composable for reusability
|
||||
|
||||
### 🔄 Improvements
|
||||
|
||||
**Sync Rate-Limiting & Battery Protection** ([ffe0e46](https://github.com/inventory69/simple-notes-sync/commit/ffe0e46), [a1a574a](https://github.com/inventory69/simple-notes-sync/commit/a1a574a))
|
||||
- Global 30-second cooldown between sync operations (auto/WiFi/periodic)
|
||||
- onSave syncs bypass global cooldown (retain own 5s throttle)
|
||||
- New `SyncStateManager` singleton for centralized state tracking
|
||||
- Prevents battery drain from rapid successive syncs
|
||||
|
||||
**Toast → Banner Migration** ([27e6b9d](https://github.com/inventory69/simple-notes-sync/commit/27e6b9d))
|
||||
- All non-interactive notifications migrated to unified Banner system
|
||||
- Server-delete results show as INFO/ERROR banners
|
||||
- Added INFO phase to SyncPhase enum with auto-hide (2.5s)
|
||||
- Snackbars with Undo actions remain unchanged
|
||||
|
||||
**ProGuard Rules Audit** ([6356173](https://github.com/inventory69/simple-notes-sync/commit/6356173))
|
||||
- Added missing keep rules for Widget ActionCallback classes
|
||||
- Added Compose-specific ProGuard rules
|
||||
- Prevents ClassNotFoundException in release builds
|
||||
|
||||
### 🧹 Code Quality
|
||||
|
||||
**Detekt Compliance** ([1a6617a](https://github.com/inventory69/simple-notes-sync/commit/1a6617a))
|
||||
- Resolved all 12 detekt findings (0 issues remaining)
|
||||
- Refactored `NoteEditorViewModel.loadNote()` to reduce nesting depth
|
||||
- Extracted constants for magic numbers in editor
|
||||
- Removed unused imports from `UpdateChangelogSheet`
|
||||
- Set `maxIssues: 0` in detekt config
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-02-10
|
||||
|
||||
### 🚨 CRITICAL BUGFIX (Tag recreated)
|
||||
@@ -890,6 +967,21 @@ The complete UI has been migrated from XML Views to Jetpack Compose. The app is
|
||||
|
||||
---
|
||||
|
||||
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
|
||||
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
|
||||
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
|
||||
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
|
||||
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
|
||||
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
|
||||
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
|
||||
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
|
||||
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
|
||||
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
|
||||
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
|
||||
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
|
||||
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
|
||||
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||
|
||||
@@ -94,10 +94,10 @@ Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes
|
||||
Dokumentations-Verbesserungen sind auch Contributions!
|
||||
|
||||
**Dateien:**
|
||||
- `README.md` / `README.en.md` - Übersicht
|
||||
- `QUICKSTART.md` / `QUICKSTART.en.md` - Schritt-für-Schritt Anleitung
|
||||
- `DOCS.md` / `DOCS.en.md` - Technische Details
|
||||
- `server/README.md` / `server/README.en.md` - Server Setup
|
||||
- `README.de.md` / `README.md` - Übersicht
|
||||
- `QUICKSTART.de.md` / `QUICKSTART.md` - Schritt-für-Schritt Anleitung
|
||||
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technische Details
|
||||
- `server/README.de.md` / `server/README.md` - Server Setup
|
||||
|
||||
**Bitte:** Halte beide Sprachen (DE/EN) synchron!
|
||||
|
||||
@@ -219,10 +219,10 @@ Use the [Feature Request Template](https://github.com/inventory69/simple-notes-s
|
||||
Documentation improvements are also contributions!
|
||||
|
||||
**Files:**
|
||||
- `README.md` / `README.en.md` - Overview
|
||||
- `QUICKSTART.md` / `QUICKSTART.en.md` - Step-by-step guide
|
||||
- `DOCS.md` / `DOCS.en.md` - Technical details
|
||||
- `server/README.md` / `server/README.en.md` - Server setup
|
||||
- `README.de.md` / `README.md` - Overview
|
||||
- `QUICKSTART.de.md` / `QUICKSTART.md` - Step-by-step guide
|
||||
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technical details
|
||||
- `server/README.de.md` / `server/README.md` - Server setup
|
||||
|
||||
**Please:** Keep both languages (DE/EN) in sync!
|
||||
|
||||
@@ -260,4 +260,4 @@ By contributing, you agree that your code will be published under the [MIT Licen
|
||||
|
||||
Öffne ein [Issue](https://github.com/inventory69/simple-notes-sync/issues) oder nutze die [Question Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose).
|
||||
|
||||
**Frohe Weihnachten & Happy Coding! 🎄**
|
||||
**Happy Coding! 🚀**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- ✅ Android 8.0+ Smartphone/Tablet
|
||||
- ✅ Android 7.0+ Smartphone/Tablet
|
||||
- ✅ WLAN-Verbindung
|
||||
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
|
||||
|
||||
@@ -52,7 +52,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
### Schritt 2: App installieren
|
||||
|
||||
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||
- Wähle: `simple-notes-sync-vX.X.X-standard.apk`
|
||||
|
||||
2. **Installation erlauben:**
|
||||
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
|
||||
@@ -261,7 +261,7 @@ Für zuverlässigen Auto-Sync:
|
||||
## 🆘 Weitere Hilfe
|
||||
|
||||
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Vollständige Docs:** [DOCS.md](DOCS.md)
|
||||
- **Vollständige Docs:** [DOCS.md](docs/DOCS.md)
|
||||
- **Server Setup Details:** [server/README.md](server/README.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ Android 8.0+ smartphone/tablet
|
||||
- ✅ Android 7.0+ smartphone/tablet
|
||||
- ✅ WiFi connection
|
||||
- ✅ Own server with Docker (optional - for self-hosting)
|
||||
|
||||
@@ -52,7 +52,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
### Step 2: Install App
|
||||
|
||||
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||
- Choose: `simple-notes-sync-vX.X.X-standard.apk`
|
||||
|
||||
2. **Allow installation:**
|
||||
- Android: Settings → Security → Enable "Unknown sources" for your browser
|
||||
@@ -77,7 +77,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
|
||||
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
|
||||
|
||||
4. **Press "Test connection"****
|
||||
4. **Press "Test connection"**
|
||||
- ✅ Success? → Continue to step 4
|
||||
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
||||
|
||||
@@ -261,8 +261,8 @@ For reliable auto-sync:
|
||||
## 🆘 Further Help
|
||||
|
||||
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
|
||||
- **Server setup details:** [server/README.en.md](server/README.en.md)
|
||||
- **Complete docs:** [DOCS.md](docs/DOCS.md)
|
||||
- **Server setup details:** [server/README.md](server/README.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
10
README.de.md
@@ -8,7 +8,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
@@ -66,12 +66,14 @@ alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||
- 📝 **Offline-first** – Funktioniert ohne Internet
|
||||
- 📊 **Flexible Ansichten** – Listen- und Grid-Layout
|
||||
- ✅ **Checklisten** – Tap-to-Check, Drag & Drop
|
||||
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||
- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
|
||||
- 📌 **Widgets** – Home-Screen Quick-Note und Notizlisten-Widget
|
||||
- 🔀 **Smartes Sortieren** – Nach Titel, Änderungsdatum, Erstelldatum, Farbe
|
||||
- ⚡ **Paralleler Sync** – Lädt bis zu 5 Notizen gleichzeitig herunter
|
||||
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||
- 🔒 **Self-hosted** – Deine Daten bleiben bei dir (WebDAV)
|
||||
- 💾 **Lokales Backup** – Export/Import als JSON-Datei (optional verschlüsselt)
|
||||
- 🖥️ **Desktop-Integration** – Markdown-Export für Obsidian, VS Code, Typora
|
||||
- 🔋 **Akkuschonend** – ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
|
||||
- 🎨 **Material Design 3** – Dynamischer Dark/Light Mode & Farben
|
||||
|
||||
➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
|
||||
@@ -138,6 +140,6 @@ MIT License – siehe [LICENSE](LICENSE)
|
||||
<div align="center">
|
||||
<br /><br />
|
||||
|
||||
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
10
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
@@ -66,12 +66,14 @@ alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||
- 📝 **Offline-first** - Works without internet
|
||||
- 📊 **Flexible views** - Switch between list and grid layout
|
||||
- ✅ **Checklists** - Tap-to-check, drag & drop
|
||||
- 🌍 **Multilingual** - English/German with language selector
|
||||
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
||||
- 📌 **Widgets** - Home screen quick-note and note list widgets
|
||||
- 🔀 **Smart sorting** - By title, date modified, date created, color
|
||||
- ⚡ **Parallel sync** - Downloads up to 5 notes simultaneously
|
||||
- 🌍 **Multilingual** - English/German with language selector
|
||||
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
||||
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
|
||||
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
||||
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
|
||||
|
||||
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
||||
@@ -148,6 +150,6 @@ MIT License - see [LICENSE](LICENSE)
|
||||
<div align="center">
|
||||
<br /><br />
|
||||
|
||||
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
||||
alias(libs.plugins.detekt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
import java.util.Properties
|
||||
@@ -20,18 +21,18 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 20 // 🎉 v1.8.0: Widgets, Sorting, UI Polish, Post-Update Changelog
|
||||
versionName = "1.8.0" // 🎉 v1.8.0: Major Feature Release
|
||||
versionCode = 21 // 🐛 v1.8.1: Checklist Fixes, Widget Sorting, ProGuard Audit
|
||||
versionName = "1.8.1" // 🐛 v1.8.1: Bugfix & Polish Release
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||
dependenciesInfo {
|
||||
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
||||
includeInBundle = false // Also disable for AAB (Google Play)
|
||||
}
|
||||
|
||||
|
||||
// Product Flavors for F-Droid and standard builds
|
||||
// Note: APK splits are disabled to ensure single APK output
|
||||
flavorDimensions += "distribution"
|
||||
@@ -42,7 +43,7 @@ android {
|
||||
// All dependencies in this project are already FOSS-compatible
|
||||
// No APK splits - F-Droid expects single universal APK
|
||||
}
|
||||
|
||||
|
||||
create("standard") {
|
||||
dimension = "distribution"
|
||||
// Standard builds can include Play Services in the future if needed
|
||||
@@ -57,7 +58,7 @@ android {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
@@ -72,11 +73,11 @@ android {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
isDebuggable = true
|
||||
|
||||
|
||||
// Optionales separates Icon-Label für Debug-Builds
|
||||
resValue("string", "app_name_debug", "Simple Notes (Debug)")
|
||||
}
|
||||
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -98,12 +99,12 @@ android {
|
||||
buildConfig = true // Enable BuildConfig generation
|
||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||
}
|
||||
|
||||
|
||||
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
|
||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
||||
// composeCompiler { }
|
||||
@@ -162,6 +163,15 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Koin
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.androidx.compose)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.0: Homescreen Widgets
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -180,7 +190,7 @@ ktlint {
|
||||
outputToConsole = true
|
||||
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
||||
enableExperimentalRules = false
|
||||
|
||||
|
||||
filter {
|
||||
exclude("**/generated/**")
|
||||
exclude("**/build/**")
|
||||
@@ -196,7 +206,7 @@ detekt {
|
||||
allRules = false
|
||||
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
|
||||
baseline = file("$rootDir/config/detekt/baseline.xml")
|
||||
|
||||
|
||||
// Parallel-Verarbeitung für schnellere Checks
|
||||
parallel = true
|
||||
}
|
||||
@@ -205,13 +215,13 @@ detekt {
|
||||
// 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
|
||||
@@ -222,11 +232,11 @@ tasks.register<Copy>("copyChangelogsToAssets") {
|
||||
relativePath = RelativePath(true, parts[0], parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
|
||||
// Run before preBuild to ensure changelogs are available
|
||||
tasks.named("preBuild") {
|
||||
dependsOn("copyChangelogsToAssets")
|
||||
}
|
||||
}
|
||||
|
||||
20
android/app/proguard-rules.pro
vendored
@@ -77,3 +77,23 @@
|
||||
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
|
||||
# This class only exists on API 35+ but Compose handles the fallback gracefully
|
||||
-dontwarn android.text.Layout$TextInclusionStrategy
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# v1.8.1: Widget & Compose Fixes
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Glance Widget ActionCallbacks (instanziiert via Reflection durch actionRunCallback<T>())
|
||||
# Ohne diese Rule findet R8 die Klassen nicht zur Laufzeit → Widget-Crash
|
||||
-keep class dev.dettmer.simplenotes.widget.*Action { *; }
|
||||
-keep class dev.dettmer.simplenotes.widget.*Receiver { *; }
|
||||
|
||||
# Glance Widget State (Preferences-basiert, intern via Reflection)
|
||||
-keep class androidx.glance.appwidget.state.** { *; }
|
||||
-keep class androidx.datastore.preferences.** { *; }
|
||||
|
||||
# Compose Text Layout: Verhindert dass R8 onTextLayout-Callbacks
|
||||
# als Side-Effect-Free optimiert (behebt Gradient-Regression)
|
||||
-keepclassmembers class androidx.compose.foundation.text.** {
|
||||
<methods>;
|
||||
}
|
||||
-keep class androidx.compose.ui.text.TextLayoutResult { *; }
|
||||
|
||||
@@ -36,8 +36,6 @@ import android.widget.CheckBox
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
@@ -56,30 +54,30 @@ import dev.dettmer.simplenotes.models.NoteType
|
||||
*/
|
||||
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
private lateinit var emptyStateCard: MaterialCardView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
private lateinit var syncStatusBanner: LinearLayout
|
||||
private lateinit var syncStatusText: TextView
|
||||
|
||||
|
||||
private lateinit var adapter: NotesAdapter
|
||||
private val storage by lazy { NotesStorage(this) }
|
||||
|
||||
|
||||
// Menu reference for sync button state
|
||||
private var optionsMenu: Menu? = null
|
||||
|
||||
|
||||
// Track pending deletions to prevent flicker when notes reload
|
||||
private val pendingDeletions = mutableSetOf<String>()
|
||||
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
@@ -89,7 +87,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
@@ -97,9 +95,9 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||
|
||||
|
||||
// UI refresh
|
||||
if (success && count > 0) {
|
||||
loadNotes()
|
||||
@@ -107,49 +105,49 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
// Apply Dynamic Colors for Android 12+ (Material You)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
|
||||
// Permission für Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
|
||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||
logLocaleInfo()
|
||||
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
setupFab()
|
||||
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
|
||||
loadNotes()
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Observe sync state for UI updates
|
||||
setupSyncStateObserver()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
|
||||
*/
|
||||
@@ -200,7 +198,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
|
||||
*/
|
||||
@@ -210,32 +208,32 @@ class MainActivity : AppCompatActivity() {
|
||||
// SwipeRefresh
|
||||
swipeRefreshLayout.isEnabled = enabled
|
||||
}
|
||||
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
|
||||
|
||||
|
||||
// Register BroadcastReceiver für Background-Sync
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
|
||||
// Reload notes (scroll to top wird in loadNotes() gemacht)
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
|
||||
triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Automatischer Sync (onResume)
|
||||
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
|
||||
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
|
||||
*
|
||||
*
|
||||
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
|
||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||
*/
|
||||
@@ -244,65 +242,65 @@ class MainActivity : AppCompatActivity() {
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running
|
||||
// v1.5.0: silent=true - kein Banner bei Auto-Sync
|
||||
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
loadNotes()
|
||||
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
@@ -310,7 +308,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
|
||||
*/
|
||||
@@ -318,96 +316,96 @@ class MainActivity : AppCompatActivity() {
|
||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSyncTime
|
||||
|
||||
|
||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
|
||||
|
||||
private fun findViews() {
|
||||
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
|
||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Sync Status Banner
|
||||
syncStatusBanner = findViewById(R.id.syncStatusBanner)
|
||||
syncStatusText = findViewById(R.id.syncStatusText)
|
||||
}
|
||||
|
||||
|
||||
private fun setupToolbar() {
|
||||
setSupportActionBar(toolbar)
|
||||
}
|
||||
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
adapter = NotesAdapter { note ->
|
||||
openNoteEditor(note.id)
|
||||
}
|
||||
recyclerViewNotes.adapter = adapter
|
||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
|
||||
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
||||
setupPullToRefresh()
|
||||
|
||||
|
||||
// Setup Swipe-to-Delete
|
||||
setupSwipeToDelete()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
|
||||
*/
|
||||
private fun setupPullToRefresh() {
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
|
||||
|
||||
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
return@setOnRefreshListener
|
||||
}
|
||||
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
|
||||
|
||||
if (serverUrl.isNullOrEmpty()) {
|
||||
showToast("⚠️ Server noch nicht konfiguriert")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Check if server is reachable
|
||||
if (!syncService.isServerReachable()) {
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Perform sync
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||
loadNotes()
|
||||
@@ -420,13 +418,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set Material 3 color scheme
|
||||
swipeRefreshLayout.setColorSchemeResources(
|
||||
com.google.android.material.R.color.material_dynamic_primary50
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun setupSwipeToDelete() {
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
0, // No drag
|
||||
@@ -437,45 +435,45 @@ class MainActivity : AppCompatActivity() {
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean = false
|
||||
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
val swipedNote = adapter.currentList[position]
|
||||
|
||||
|
||||
// Store original list BEFORE removing note
|
||||
val originalList = adapter.currentList.toList()
|
||||
|
||||
|
||||
// Remove from list for visual feedback (NOT from storage yet!)
|
||||
val listWithoutNote = originalList.toMutableList().apply {
|
||||
removeAt(position)
|
||||
}
|
||||
adapter.submitList(listWithoutNote)
|
||||
|
||||
|
||||
// Show dialog with ability to restore
|
||||
showServerDeletionDialog(swipedNote, originalList)
|
||||
}
|
||||
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
// Require 80% swipe to trigger
|
||||
return 0.8f
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
|
||||
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
|
||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||
|
||||
|
||||
if (alwaysDeleteFromServer) {
|
||||
// Auto-delete from server without asking
|
||||
deleteNoteLocally(note, deleteFromServer = true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
|
||||
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
||||
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.legacy_delete_dialog_title))
|
||||
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
|
||||
@@ -504,24 +502,24 @@ class MainActivity : AppCompatActivity() {
|
||||
.setCancelable(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
||||
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
|
||||
// Track pending deletion to prevent flicker
|
||||
pendingDeletions.add(note.id)
|
||||
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
|
||||
// Reload to reflect changes
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Show Snackbar with UNDO option
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.legacy_delete_with_server, note.title)
|
||||
} else {
|
||||
getString(R.string.legacy_delete_local_only, note.title)
|
||||
}
|
||||
|
||||
|
||||
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
||||
.setAction(getString(R.string.snackbar_undo)) {
|
||||
// UNDO: Restore note
|
||||
@@ -534,7 +532,7 @@ class MainActivity : AppCompatActivity() {
|
||||
if (event != DISMISS_EVENT_ACTION) {
|
||||
// Snackbar dismissed without UNDO
|
||||
pendingDeletions.remove(note.id)
|
||||
|
||||
|
||||
// Delete from server if requested
|
||||
if (deleteFromServer) {
|
||||
lifecycleScope.launch {
|
||||
@@ -573,7 +571,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}).show()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||
*/
|
||||
@@ -582,14 +580,14 @@ class MainActivity : AppCompatActivity() {
|
||||
showNoteTypePopup(view)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
||||
*/
|
||||
private fun showNoteTypePopup(anchor: View) {
|
||||
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
||||
popupMenu.inflate(R.menu.menu_fab_note_types)
|
||||
|
||||
|
||||
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
||||
try {
|
||||
val fields = popupMenu.javaClass.declaredFields
|
||||
@@ -606,29 +604,29 @@ class MainActivity : AppCompatActivity() {
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
||||
}
|
||||
|
||||
|
||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||
val noteType = when (menuItem.itemId) {
|
||||
R.id.action_create_text_note -> NoteType.TEXT
|
||||
R.id.action_create_checklist -> NoteType.CHECKLIST
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
||||
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
popupMenu.show()
|
||||
}
|
||||
|
||||
|
||||
private fun loadNotes() {
|
||||
val notes = storage.loadAllNotes()
|
||||
|
||||
|
||||
// Filter out notes that are pending deletion (prevent flicker)
|
||||
val filteredNotes = notes.filter { it.id !in pendingDeletions }
|
||||
|
||||
|
||||
// Submit list with callback to scroll to top after list is updated
|
||||
adapter.submitList(filteredNotes) {
|
||||
// Scroll to top after list update is complete
|
||||
@@ -637,7 +635,7 @@ class MainActivity : AppCompatActivity() {
|
||||
recyclerViewNotes.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Material 3 Empty State Card
|
||||
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
|
||||
android.view.View.VISIBLE
|
||||
@@ -645,7 +643,7 @@ class MainActivity : AppCompatActivity() {
|
||||
android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
@@ -653,25 +651,25 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
|
||||
private fun openSettings() {
|
||||
// v1.5.0: Use new Jetpack Compose Settings
|
||||
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS)
|
||||
}
|
||||
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Create sync service
|
||||
val syncService = WebDavSyncService(this@MainActivity)
|
||||
|
||||
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
@@ -679,23 +677,23 @@ class MainActivity : AppCompatActivity() {
|
||||
SyncStateManager.markCompleted(message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
@@ -703,19 +701,19 @@ class MainActivity : AppCompatActivity() {
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_settings -> {
|
||||
@@ -729,10 +727,10 @@ class MainActivity : AppCompatActivity() {
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
@@ -741,50 +739,50 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Restore was successful, reload notes
|
||||
loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
||||
*
|
||||
*
|
||||
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
||||
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
||||
*
|
||||
*
|
||||
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
||||
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
||||
*
|
||||
*
|
||||
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
||||
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
||||
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
|
||||
// Nur einmal ausführen
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// generiert und hochgeladen wird
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
||||
@@ -792,24 +790,24 @@ class MainActivity : AppCompatActivity() {
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
|
||||
// Migration als erledigt markieren
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showToast(getString(R.string.toast_notifications_enabled))
|
||||
} else {
|
||||
@@ -818,39 +816,39 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
|
||||
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
|
||||
*/
|
||||
private fun logLocaleInfo() {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
|
||||
|
||||
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
|
||||
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
|
||||
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
|
||||
|
||||
|
||||
// System Locale
|
||||
val systemLocale = java.util.Locale.getDefault()
|
||||
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
|
||||
|
||||
|
||||
// Resources Locale
|
||||
val resourcesLocale = resources.configuration.locales[0]
|
||||
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
|
||||
|
||||
|
||||
// Context Locale (API 24+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val contextLocales = resources.configuration.locales
|
||||
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
|
||||
}
|
||||
|
||||
|
||||
// Test String Loading
|
||||
val testString = getString(R.string.toast_already_synced)
|
||||
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
|
||||
Logger.d(TAG, "║ Result: '$testString'")
|
||||
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
|
||||
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
|
||||
|
||||
|
||||
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@ import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.di.appModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.startKoin
|
||||
|
||||
class SimpleNotesApplication : Application() {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SimpleNotesApp"
|
||||
}
|
||||
|
||||
|
||||
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
|
||||
|
||||
|
||||
/**
|
||||
* 🌍 v1.7.1: Apply app locale to Application Context
|
||||
*
|
||||
*
|
||||
* This ensures ViewModels and other components using Application Context
|
||||
* get the correct locale-specific strings.
|
||||
*/
|
||||
@@ -26,71 +30,77 @@ class SimpleNotesApplication : Application() {
|
||||
// This is handled by AppCompatDelegate which reads from system storage
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
||||
startKoin {
|
||||
androidLogger() // Log Koin events
|
||||
androidContext(this@SimpleNotesApplication) // Provide context to modules
|
||||
modules(appModule)
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
|
||||
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
|
||||
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
|
||||
// appear as offline even though they have a configured server
|
||||
migrateOfflineModeSetting(prefs)
|
||||
|
||||
|
||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "🚀 Application onCreate()")
|
||||
|
||||
|
||||
// Initialize notification channel
|
||||
NotificationHelper.createNotificationChannel(this)
|
||||
Logger.d(TAG, "✅ Notification channel created")
|
||||
|
||||
|
||||
// Initialize NetworkMonitor (WorkManager-based)
|
||||
// VORTEIL: WorkManager läuft auch ohne aktive App!
|
||||
networkMonitor = NetworkMonitor(applicationContext)
|
||||
|
||||
|
||||
// Start WorkManager periodic sync
|
||||
// Dies läuft im Hintergrund auch wenn App geschlossen ist
|
||||
networkMonitor.startMonitoring()
|
||||
|
||||
|
||||
Logger.d(TAG, "✅ WorkManager-based auto-sync initialized")
|
||||
}
|
||||
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
|
||||
|
||||
Logger.d(TAG, "🛑 Application onTerminate()")
|
||||
|
||||
|
||||
// WorkManager läuft weiter auch nach onTerminate!
|
||||
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
|
||||
*
|
||||
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
||||
* and NoteEditorViewModel use `true` as default, causing existing users
|
||||
*
|
||||
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
||||
* and NoteEditorViewModel use `true` as default, causing existing users
|
||||
* with configured servers to appear in offline mode after update.
|
||||
*
|
||||
*
|
||||
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
|
||||
* a server was already configured.
|
||||
*/
|
||||
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
|
||||
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
|
||||
|
||||
// If server was configured → offlineMode = false (continue syncing)
|
||||
// If no server → offlineMode = true (new users / offline users)
|
||||
val offlineModeValue = !hasServerConfig
|
||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
|
||||
|
||||
|
||||
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.dettmer.simplenotes.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dev.dettmer.simplenotes.storage.AppDatabase
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.ui.main.MainViewModel
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
single {
|
||||
Room.databaseBuilder(
|
||||
androidContext(),
|
||||
AppDatabase::class.java,
|
||||
"notes_database"
|
||||
).build()
|
||||
}
|
||||
|
||||
single { get<AppDatabase>().noteDao() }
|
||||
single { get<AppDatabase>().deletedNoteDao() }
|
||||
|
||||
// Provide SharedPreferences
|
||||
single {
|
||||
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
single { NotesStorage(androidContext(), get()) }
|
||||
|
||||
viewModel { MainViewModel(get(), get()) }
|
||||
}
|
||||
@@ -24,7 +24,9 @@ data class Note(
|
||||
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
|
||||
// v1.4.0: Checklisten-Felder
|
||||
val noteType: NoteType = NoteType.TEXT,
|
||||
val checklistItems: List<ChecklistItem>? = null
|
||||
val checklistItems: List<ChecklistItem>? = null,
|
||||
// 🆕 v1.8.1 (IMPL_03): Persistierte Sortierung
|
||||
val checklistSortOption: String? = null
|
||||
) {
|
||||
/**
|
||||
* Serialisiert Note zu JSON
|
||||
@@ -71,13 +73,20 @@ data class Note(
|
||||
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||
*/
|
||||
fun toMarkdown(): String {
|
||||
// 🆕 v1.8.1 (IMPL_03): Sortierung im Frontmatter
|
||||
val sortLine = if (noteType == NoteType.CHECKLIST && checklistSortOption != null) {
|
||||
"\nsort: $checklistSortOption"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val header = """
|
||||
---
|
||||
id: $id
|
||||
created: ${formatISO8601(createdAt)}
|
||||
updated: ${formatISO8601(updatedAt)}
|
||||
device: $deviceId
|
||||
type: ${noteType.name.lowercase()}
|
||||
type: ${noteType.name.lowercase()}$sortLine
|
||||
---
|
||||
|
||||
# $title
|
||||
@@ -119,6 +128,14 @@ type: ${noteType.name.lowercase()}
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
|
||||
val checklistSortOption = if (jsonObject.has("checklistSortOption") &&
|
||||
!jsonObject.get("checklistSortOption").isJsonNull) {
|
||||
jsonObject.get("checklistSortOption").asString
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Parsen der Basis-Note
|
||||
val rawNote = gson.fromJson(json, NoteRaw::class.java)
|
||||
|
||||
@@ -158,7 +175,8 @@ type: ${noteType.name.lowercase()}
|
||||
deviceId = rawNote.deviceId,
|
||||
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
|
||||
noteType = noteType,
|
||||
checklistItems = checklistItems
|
||||
checklistItems = checklistItems,
|
||||
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
|
||||
@@ -246,6 +264,9 @@ type: ${noteType.name.lowercase()}
|
||||
else -> NoteType.TEXT
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung aus YAML laden
|
||||
val checklistSortOption = metadata["sort"]
|
||||
|
||||
// v1.4.0: Parse Content basierend auf Typ
|
||||
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
|
||||
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
|
||||
@@ -300,7 +321,8 @@ type: ${noteType.name.lowercase()}
|
||||
deviceId = metadata["device"] ?: "desktop",
|
||||
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
||||
noteType = noteType,
|
||||
checklistItems = checklistItems
|
||||
checklistItems = checklistItems,
|
||||
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
|
||||
import dev.dettmer.simplenotes.storage.dao.NoteDao
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
|
||||
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun noteDao(): NoteDao
|
||||
abstract fun deletedNoteDao(): DeletedNoteDao
|
||||
}
|
||||
@@ -1,77 +1,59 @@
|
||||
package dev.dettmer.simplenotes.storage
|
||||
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
class NotesStorage(private val context: Context) {
|
||||
|
||||
class NotesStorage(
|
||||
private val context: Context,
|
||||
database: AppDatabase
|
||||
) {
|
||||
|
||||
companion object {
|
||||
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 {
|
||||
if (!exists()) mkdirs()
|
||||
|
||||
private val noteDao = database.noteDao()
|
||||
private val deletedNoteDao = database.deletedNoteDao()
|
||||
|
||||
suspend fun saveNote(note: NoteEntity) {
|
||||
noteDao.saveNote(note)
|
||||
}
|
||||
|
||||
fun saveNote(note: Note) {
|
||||
val file = File(notesDir, "${note.id}.json")
|
||||
file.writeText(note.toJson())
|
||||
|
||||
suspend fun loadNote(id: String): NoteEntity? {
|
||||
return noteDao.getNote(id)
|
||||
}
|
||||
|
||||
fun loadNote(id: String): Note? {
|
||||
val file = File(notesDir, "$id.json")
|
||||
return if (file.exists()) {
|
||||
Note.fromJson(file.readText())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun loadAllNotes(): List<NoteEntity> {
|
||||
return noteDao.getAllNotes()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.mapNotNull { Note.fromJson(it.readText()) }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
fun deleteNote(id: String): Boolean {
|
||||
val file = File(notesDir, "$id.json")
|
||||
val deleted = file.delete()
|
||||
|
||||
if (deleted) {
|
||||
|
||||
suspend fun deleteNote(id: String): Boolean {
|
||||
val deletedRows = noteDao.deleteNoteById(id)
|
||||
|
||||
if (deletedRows > 0) {
|
||||
Logger.d(TAG, "🗑️ Deleted note: $id")
|
||||
|
||||
// Track deletion to prevent zombie notes
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
trackDeletion(id, deviceId)
|
||||
trackDeletionSafe(id, deviceId)
|
||||
return true
|
||||
}
|
||||
|
||||
return deleted
|
||||
return false
|
||||
}
|
||||
|
||||
fun deleteAllNotes(): Boolean {
|
||||
|
||||
suspend fun deleteAllNotes(): Boolean {
|
||||
return try {
|
||||
val notes = loadAllNotes()
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
|
||||
for (note in notes) {
|
||||
deleteNote(note.id) // Uses trackDeletion() automatically
|
||||
|
||||
// Batch tracking and deleting
|
||||
notes.forEach { note ->
|
||||
trackDeletionSafe(note.id, deviceId)
|
||||
}
|
||||
|
||||
noteDao.deleteAllNotes()
|
||||
|
||||
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -79,104 +61,31 @@ class NotesStorage(private val context: Context) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Deletion Tracking ===
|
||||
|
||||
private fun getDeletionTrackerFile(): File {
|
||||
return File(context.filesDir, "deleted_notes.json")
|
||||
}
|
||||
|
||||
fun loadDeletionTracker(): DeletionTracker {
|
||||
val file = getDeletionTrackerFile()
|
||||
if (!file.exists()) {
|
||||
return DeletionTracker()
|
||||
}
|
||||
|
||||
return try {
|
||||
val json = file.readText()
|
||||
DeletionTracker.fromJson(json) ?: DeletionTracker()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to load deletion tracker", e)
|
||||
DeletionTracker()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDeletionTracker(tracker: DeletionTracker) {
|
||||
try {
|
||||
val file = getDeletionTrackerFile()
|
||||
file.writeText(tracker.toJson())
|
||||
|
||||
if (tracker.deletedNotes.size > 1000) {
|
||||
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to save deletion tracker", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 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) {
|
||||
val tracker = loadDeletionTracker()
|
||||
tracker.addDeletion(noteId, deviceId)
|
||||
saveDeletionTracker(tracker)
|
||||
// Room handles internal transactions and thread-safety natively.
|
||||
// The Mutex is no longer required.
|
||||
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
|
||||
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
||||
}
|
||||
|
||||
fun isNoteDeleted(noteId: String): Boolean {
|
||||
val tracker = loadDeletionTracker()
|
||||
return tracker.isDeleted(noteId)
|
||||
|
||||
suspend fun isNoteDeleted(noteId: String): Boolean {
|
||||
return deletedNoteDao.isNoteDeleted(noteId)
|
||||
}
|
||||
|
||||
fun clearDeletionTracker() {
|
||||
saveDeletionTracker(DeletionTracker())
|
||||
|
||||
suspend fun clearDeletionTracker() {
|
||||
deletedNoteDao.clearTracker()
|
||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
|
||||
* This ensures notes are uploaded to the new server on next sync
|
||||
*/
|
||||
fun resetAllSyncStatusToPending(): Int {
|
||||
val notes = loadAllNotes()
|
||||
var updatedCount = 0
|
||||
|
||||
notes.forEach { note ->
|
||||
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
|
||||
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
|
||||
saveNote(updatedNote)
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun resetAllSyncStatusToPending(): Int {
|
||||
val updatedCount = noteDao.updateSyncStatus(
|
||||
oldStatus = SyncStatus.SYNCED,
|
||||
newStatus = SyncStatus.PENDING
|
||||
)
|
||||
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
||||
return updatedCount
|
||||
}
|
||||
|
||||
|
||||
fun getNotesDir(): File = notesDir
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.dettmer.simplenotes.storage.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
|
||||
|
||||
@Dao
|
||||
interface DeletedNoteDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun trackDeletion(deletedNote: DeletedNoteEntity)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM deleted_notes WHERE noteId = :noteId)")
|
||||
suspend fun isNoteDeleted(noteId: String): Boolean
|
||||
|
||||
@Query("DELETE FROM deleted_notes")
|
||||
suspend fun clearTracker()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.dettmer.simplenotes.storage.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.entity.NoteEntity
|
||||
|
||||
@Dao
|
||||
interface NoteDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun saveNote(note: NoteEntity)
|
||||
|
||||
@Query("SELECT * FROM notes WHERE id = :id")
|
||||
suspend fun getNote(id: String): NoteEntity?
|
||||
|
||||
@Query("SELECT * FROM notes")
|
||||
suspend fun getAllNotes(): List<NoteEntity>
|
||||
|
||||
@Query("DELETE FROM notes WHERE id = :id")
|
||||
suspend fun deleteNoteById(id: String): Int
|
||||
|
||||
@Query("DELETE FROM notes")
|
||||
suspend fun deleteAllNotes(): Int
|
||||
|
||||
@Query("UPDATE notes SET syncStatus = :newStatus WHERE syncStatus = :oldStatus")
|
||||
suspend fun updateSyncStatus(oldStatus: SyncStatus, newStatus: SyncStatus): Int
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.dettmer.simplenotes.storage.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "deleted_notes")
|
||||
data class DeletedNoteEntity(
|
||||
@PrimaryKey val noteId: String,
|
||||
val deviceId: String,
|
||||
val deletedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.dettmer.simplenotes.storage.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
|
||||
@Entity(tableName = "notes")
|
||||
data class NoteEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
val syncStatus: SyncStatus
|
||||
)
|
||||
@@ -52,9 +52,10 @@ data class SyncProgress(
|
||||
/**
|
||||
* Ob das Banner sichtbar sein soll
|
||||
* Silent syncs zeigen nie ein Banner
|
||||
* 🆕 v1.8.1 (IMPL_12): INFO ist immer sichtbar (nicht vom silent-Flag betroffen)
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = !silent && phase != SyncPhase.IDLE
|
||||
get() = phase == SyncPhase.INFO || (!silent && phase != SyncPhase.IDLE)
|
||||
|
||||
/**
|
||||
* Ob gerade ein aktiver Sync läuft (mit Spinner)
|
||||
@@ -95,5 +96,8 @@ enum class SyncPhase {
|
||||
COMPLETED,
|
||||
|
||||
/** Sync mit Fehler abgebrochen */
|
||||
ERROR
|
||||
ERROR,
|
||||
|
||||
/** 🆕 v1.8.1 (IMPL_12): Kurzfristige Info-Meldung (nicht sync-bezogen) */
|
||||
INFO
|
||||
}
|
||||
|
||||
@@ -214,4 +214,91 @@ object SyncStateManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Prüft ob seit dem letzten erfolgreichen Sync-Start genügend Zeit vergangen ist.
|
||||
* Wird von ALLEN Sync-Triggern als erste Prüfung aufgerufen.
|
||||
*
|
||||
* @return true wenn ein neuer Sync erlaubt ist
|
||||
*/
|
||||
fun canSyncGlobally(prefs: android.content.SharedPreferences): Boolean {
|
||||
val lastGlobalSync = prefs.getLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastGlobalSync
|
||||
|
||||
if (elapsed < dev.dettmer.simplenotes.utils.Constants.MIN_GLOBAL_SYNC_INTERVAL_MS) {
|
||||
val remainingSec = (dev.dettmer.simplenotes.utils.Constants.MIN_GLOBAL_SYNC_INTERVAL_MS - elapsed) / 1000
|
||||
dev.dettmer.simplenotes.utils.Logger.d(TAG, "⏳ Global sync cooldown active - wait ${remainingSec}s")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert den aktuellen Zeitpunkt als letzten Sync-Start (global).
|
||||
* Aufzurufen wenn ein Sync tatsächlich startet (nach allen Checks).
|
||||
*/
|
||||
fun markGlobalSyncStarted(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_12): Info-Meldungen über das Banner-System
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Zeigt eine kurzfristige Info-Meldung im Banner an.
|
||||
* Wird für nicht-sync-bezogene Benachrichtigungen verwendet
|
||||
* (z.B. Server-Delete-Ergebnisse).
|
||||
*
|
||||
* ACHTUNG: Wenn gerade ein Sync läuft (isSyncing), wird die Meldung
|
||||
* ignoriert — der Sync-Progress hat Vorrang.
|
||||
*
|
||||
* Auto-Hide erfolgt über ComposeMainActivity (2.5s).
|
||||
*/
|
||||
fun showInfo(message: String) {
|
||||
synchronized(lock) {
|
||||
// Nicht während aktivem Sync anzeigen — Sync-Fortschritt hat Vorrang
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "ℹ️ Info suppressed during sync: $message")
|
||||
return
|
||||
}
|
||||
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.INFO,
|
||||
resultMessage = message,
|
||||
silent = false // INFO ist nie silent
|
||||
)
|
||||
|
||||
Logger.d(TAG, "ℹ️ Showing info: $message")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt eine Fehlermeldung im Banner an, auch außerhalb eines Syncs.
|
||||
* Für nicht-sync-bezogene Fehler (z.B. Server-Delete fehlgeschlagen).
|
||||
*
|
||||
* Auto-Hide erfolgt über ComposeMainActivity (4s).
|
||||
*/
|
||||
fun showError(message: String?) {
|
||||
synchronized(lock) {
|
||||
// Nicht während aktivem Sync anzeigen
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "❌ Error suppressed during sync: $message")
|
||||
return
|
||||
}
|
||||
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.ERROR,
|
||||
resultMessage = message,
|
||||
silent = false
|
||||
)
|
||||
|
||||
Logger.e(TAG, "❌ Showing error: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ class SyncWorker(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod") // Linear sync flow with debug logging — splitting would hurt readability
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -104,7 +105,42 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)")
|
||||
Logger.d(TAG, "📍 Step 2: SyncStateManager coordination & global cooldown (v1.8.1)")
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): SyncStateManager-Koordination
|
||||
// Verhindert dass Foreground und Background gleichzeitig syncing-State haben
|
||||
val prefs = applicationContext.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08B): onSave-Syncs bypassen den globalen Cooldown
|
||||
// Grund: User hat explizit gespeichert → erwartet zeitnahen Sync
|
||||
// Der eigene 5s-Throttle + isSyncing-Mutex reichen als Schutz
|
||||
val isOnSaveSync = tags.contains(Constants.SYNC_ONSAVE_TAG)
|
||||
|
||||
// Globaler Cooldown-Check (nicht für onSave-Syncs)
|
||||
if (!isOnSaveSync && !SyncStateManager.canSyncGlobally(prefs)) {
|
||||
Logger.d(TAG, "⏭️ SyncWorker: Global sync cooldown active - skipping")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cooldown)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (!SyncStateManager.tryStartSync("worker-${tags.firstOrNull() ?: "unknown"}", silent = true)) {
|
||||
Logger.d(TAG, "⏭️ SyncWorker: Another sync already in progress - skipping")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (already syncing)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
// Globalen Cooldown markieren
|
||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Checking for unsynced changes (Performance Pre-Check)")
|
||||
}
|
||||
|
||||
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
|
||||
@@ -122,7 +158,7 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 2.5: Checking sync gate (canSync)")
|
||||
Logger.d(TAG, "📍 Step 4: Checking sync gate (canSync)")
|
||||
}
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
|
||||
@@ -143,7 +179,7 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
||||
Logger.d(TAG, "📍 Step 5: Checking server reachability (Pre-Check)")
|
||||
}
|
||||
|
||||
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
|
||||
@@ -167,7 +203,7 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync")
|
||||
Logger.d(TAG, "📍 Step 6: Server reachable - proceeding with sync")
|
||||
Logger.d(TAG, " SyncService: $syncService")
|
||||
}
|
||||
|
||||
@@ -188,7 +224,7 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Processing result")
|
||||
Logger.d(TAG, "📍 Step 7: Processing result")
|
||||
Logger.d(
|
||||
TAG,
|
||||
"📦 Sync result: success=${result.isSuccess}, " +
|
||||
@@ -198,10 +234,13 @@ class SyncWorker(
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 5: Success path")
|
||||
Logger.d(TAG, "📍 Step 8: Success path")
|
||||
}
|
||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
||||
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
|
||||
if (result.syncedCount > 0) {
|
||||
@@ -248,9 +287,13 @@ class SyncWorker(
|
||||
Result.success()
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 5: Failure path")
|
||||
Logger.d(TAG, "📍 Step 8: Failure path")
|
||||
}
|
||||
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
|
||||
NotificationHelper.showSyncError(
|
||||
applicationContext,
|
||||
result.errorMessage ?: "Unbekannter Fehler"
|
||||
|
||||
@@ -27,6 +27,16 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Cooldown (verhindert Doppel-Trigger mit NetworkMonitor)
|
||||
if (!SyncStateManager.canSyncGlobally(prefs)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Auch KEY_SYNC_TRIGGER_WIFI_CONNECT prüfen (Konsistenz mit NetworkMonitor)
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
|
||||
if (isConnectedToWifi(context)) {
|
||||
scheduleSyncWork(context)
|
||||
|
||||
@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
|
||||
* 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)
|
||||
* v1.8.1: IMPL_14 - Separator als eigenes Item, Cross-Boundary-Drag mit Auto-Toggle
|
||||
*/
|
||||
class DragDropListState(
|
||||
private val state: LazyListState,
|
||||
@@ -36,8 +37,14 @@ class DragDropListState(
|
||||
|
||||
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||
private var draggingItemInitialOffset by mutableFloatStateOf(0f)
|
||||
// 🆕 v1.8.1: Item-Größe beim Drag-Start fixieren
|
||||
// Verhindert dass Höhenänderungen die Swap-Erkennung destabilisieren
|
||||
private var draggingItemSize by mutableStateOf(0)
|
||||
private var overscrollJob by mutableStateOf<Job?>(null)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Visual-Index des Separators (-1 = kein Separator)
|
||||
var separatorVisualIndex by mutableStateOf(-1)
|
||||
|
||||
val draggingItemOffset: Float
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
@@ -47,9 +54,28 @@ class DragDropListState(
|
||||
get() = state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Visual-Index → Data-Index Konvertierung.
|
||||
* Wenn ein Separator existiert, sind alle Items nach dem Separator um 1 verschoben.
|
||||
*/
|
||||
fun visualToDataIndex(visualIndex: Int): Int {
|
||||
if (separatorVisualIndex < 0) return visualIndex
|
||||
return if (visualIndex > separatorVisualIndex) visualIndex - 1 else visualIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Data-Index → Visual-Index Konvertierung.
|
||||
*/
|
||||
fun dataToVisualIndex(dataIndex: Int): Int {
|
||||
if (separatorVisualIndex < 0) return dataIndex
|
||||
return if (dataIndex >= separatorVisualIndex) dataIndex + 1 else dataIndex
|
||||
}
|
||||
|
||||
fun onDragStart(offset: Offset, itemIndex: Int) {
|
||||
draggingItemIndex = itemIndex
|
||||
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f
|
||||
val info = draggingItemLayoutInfo
|
||||
draggingItemInitialOffset = info?.offset?.toFloat() ?: 0f
|
||||
draggingItemSize = info?.size ?: 0
|
||||
draggingItemDraggedDelta = 0f
|
||||
}
|
||||
|
||||
@@ -57,6 +83,7 @@ class DragDropListState(
|
||||
draggingItemDraggedDelta = 0f
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = 0f
|
||||
draggingItemSize = 0
|
||||
overscrollJob?.cancel()
|
||||
}
|
||||
|
||||
@@ -65,15 +92,19 @@ class DragDropListState(
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size
|
||||
// 🆕 v1.8.1: Fixierte Item-Größe für stabile Swap-Erkennung
|
||||
val endOffset = startOffset + draggingItemSize
|
||||
|
||||
// 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
|
||||
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
|
||||
// wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
|
||||
// Dies verhindert Oszillation bei Items unterschiedlicher Größe.
|
||||
// Zusätzlich: Nur adjazente Items (Index ± 1) als Swap-Kandidaten.
|
||||
// 🆕 v1.8.1 IMPL_14: Separator überspringen, Adjazenz berücksichtigt Separator-Lücke
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||
(item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) &&
|
||||
// Separator überspringen
|
||||
item.index != separatorVisualIndex &&
|
||||
// Nur adjazente Items (Separator-Lücke wird übersprungen)
|
||||
isAdjacentSkippingSeparator(draggingItem.index, item.index) &&
|
||||
run {
|
||||
val targetCenter = item.offset + item.size / 2
|
||||
startOffset < targetCenter && endOffset > targetCenter
|
||||
@@ -88,16 +119,20 @@ class DragDropListState(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Visual-Indizes zu Data-Indizes konvertieren für onMove
|
||||
val fromDataIndex = visualToDataIndex(draggingItem.index)
|
||||
val toDataIndex = visualToDataIndex(targetItem.index)
|
||||
|
||||
if (scrollToIndex != null) {
|
||||
scope.launch {
|
||||
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||
onMove(draggingItem.index, targetItem.index)
|
||||
onMove(fromDataIndex, toDataIndex)
|
||||
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
|
||||
draggingItemIndex = targetItem.index
|
||||
}
|
||||
} else {
|
||||
onMove(draggingItem.index, targetItem.index)
|
||||
onMove(fromDataIndex, toDataIndex)
|
||||
draggingItemIndex = targetItem.index
|
||||
}
|
||||
} else {
|
||||
@@ -121,6 +156,26 @@ class DragDropListState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Prüft ob zwei Visual-Indizes adjazent sind,
|
||||
* wobei der Separator übersprungen wird.
|
||||
* Beispiel: Items bei Visual 1 und Visual 3 sind adjazent wenn Separator bei Visual 2 liegt.
|
||||
*/
|
||||
private fun isAdjacentSkippingSeparator(indexA: Int, indexB: Int): Boolean {
|
||||
val diff = kotlin.math.abs(indexA - indexB)
|
||||
if (diff == 1) {
|
||||
// Direkt benachbart — aber NICHT wenn der Separator dazwischen liegt
|
||||
val between = minOf(indexA, indexB) + 1
|
||||
return between != separatorVisualIndex || separatorVisualIndex < 0
|
||||
}
|
||||
if (diff == 2 && separatorVisualIndex >= 0) {
|
||||
// 2 Positionen entfernt — adjazent wenn Separator dazwischen
|
||||
val between = minOf(indexA, indexB) + 1
|
||||
return between == separatorVisualIndex
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -70,8 +66,10 @@ import dev.dettmer.simplenotes.utils.showToast
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val LAYOUT_DELAY_MS = 100L
|
||||
private const val AUTO_SCROLL_DELAY_MS = 50L
|
||||
private const val ITEM_CORNER_RADIUS_DP = 8
|
||||
private const val DRAGGING_ITEM_Z_INDEX = 10f
|
||||
private val DRAGGING_ELEVATION_DP = 8.dp
|
||||
|
||||
/**
|
||||
* Main Composable for the Note Editor screen.
|
||||
@@ -327,6 +325,66 @@ private fun TextNoteContent(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Extrahiertes Composable für ein einzelnes draggbares Checklist-Item.
|
||||
* Entkoppelt von der Separator-Logik — wiederverwendbar für unchecked und checked Items.
|
||||
*/
|
||||
@Suppress("LongParameterList") // Compose callbacks — cannot be reduced without wrapper class
|
||||
@Composable
|
||||
private fun LazyItemScope.DraggableChecklistItem(
|
||||
item: ChecklistItemState,
|
||||
visualIndex: Int,
|
||||
dragDropState: DragDropListState,
|
||||
focusNewItemId: String?,
|
||||
onTextChange: (String, String) -> Unit,
|
||||
onCheckedChange: (String, Boolean) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onAddNewItemAfter: (String) -> Unit,
|
||||
onFocusHandled: () -> Unit,
|
||||
onHeightChanged: () -> Unit, // 🆕 v1.8.1 (IMPL_05)
|
||||
) {
|
||||
val isDragging = dragDropState.draggingItemIndex == visualIndex
|
||||
val elevation by animateDpAsState(
|
||||
targetValue = if (isDragging) DRAGGING_ELEVATION_DP else 0.dp,
|
||||
label = "elevation"
|
||||
)
|
||||
|
||||
val shouldFocus = item.id == focusNewItemId
|
||||
|
||||
LaunchedEffect(shouldFocus) {
|
||||
if (shouldFocus) {
|
||||
onFocusHandled()
|
||||
}
|
||||
}
|
||||
|
||||
ChecklistItemRow(
|
||||
item = item,
|
||||
onTextChange = { onTextChange(item.id, it) },
|
||||
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||
onDelete = { onDelete(item.id) },
|
||||
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||
requestFocus = shouldFocus,
|
||||
isDragging = isDragging,
|
||||
isAnyItemDragging = dragDropState.draggingItemIndex != null,
|
||||
dragModifier = Modifier.dragContainer(dragDropState, visualIndex),
|
||||
onHeightChanged = onHeightChanged, // 🆕 v1.8.1 (IMPL_05)
|
||||
modifier = Modifier
|
||||
.then(if (!isDragging) Modifier.animateItem() else Modifier)
|
||||
.offset {
|
||||
IntOffset(
|
||||
0,
|
||||
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
||||
)
|
||||
}
|
||||
.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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
|
||||
@Composable
|
||||
private fun ChecklistEditor(
|
||||
@@ -351,6 +409,9 @@ private fun ChecklistEditor(
|
||||
onMove = onMove
|
||||
)
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll bei Zeilenumbruch
|
||||
var scrollToItemIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// 🆕 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 }
|
||||
@@ -359,70 +420,78 @@ private fun ChecklistEditor(
|
||||
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
|
||||
|
||||
Column(modifier = modifier) {
|
||||
// 🆕 v1.8.1 IMPL_14: Separator-Position für DragDropState aktualisieren
|
||||
val separatorVisualIndex = if (showSeparator) uncheckedCount else -1
|
||||
LaunchedEffect(separatorVisualIndex) {
|
||||
dragDropState.separatorVisualIndex = separatorVisualIndex
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll wenn ein Item durch Zeilenumbruch wächst
|
||||
LaunchedEffect(scrollToItemIndex) {
|
||||
scrollToItemIndex?.let { index ->
|
||||
delay(AUTO_SCROLL_DELAY_MS) // Warten bis Layout-Pass abgeschlossen
|
||||
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
if (index >= lastVisibleIndex - 1) {
|
||||
listState.animateScrollToItem(
|
||||
index = minOf(index + 1, items.size + if (showSeparator) 1 else 0),
|
||||
scrollOffset = 0
|
||||
)
|
||||
}
|
||||
scrollToItemIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
|
||||
itemsIndexed(
|
||||
items = items,
|
||||
items = if (showSeparator) items.subList(0, uncheckedCount) else items,
|
||||
key = { _, item -> item.id }
|
||||
) { 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 elevation by animateDpAsState(
|
||||
targetValue = if (isDragging) 8.dp else 0.dp,
|
||||
label = "elevation"
|
||||
DraggableChecklistItem(
|
||||
item = item,
|
||||
visualIndex = index,
|
||||
dragDropState = dragDropState,
|
||||
focusNewItemId = focusNewItemId,
|
||||
onTextChange = onTextChange,
|
||||
onCheckedChange = onCheckedChange,
|
||||
onDelete = onDelete,
|
||||
onAddNewItemAfter = onAddNewItemAfter,
|
||||
onFocusHandled = onFocusHandled,
|
||||
onHeightChanged = { scrollToItemIndex = index } // 🆕 v1.8.1 (IMPL_05)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldFocus = item.id == focusNewItemId
|
||||
|
||||
// v1.5.0: Clear focus request after handling
|
||||
LaunchedEffect(shouldFocus) {
|
||||
if (shouldFocus) {
|
||||
onFocusHandled()
|
||||
}
|
||||
// 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
|
||||
if (showSeparator) {
|
||||
item(key = "separator") {
|
||||
CheckedItemsSeparator(
|
||||
checkedCount = checkedCount,
|
||||
isDragActive = dragDropState.draggingItemIndex != null
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically()
|
||||
) {
|
||||
ChecklistItemRow(
|
||||
// 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
|
||||
itemsIndexed(
|
||||
items = items.subList(uncheckedCount, items.size),
|
||||
key = { _, item -> item.id }
|
||||
) { index, item ->
|
||||
val visualIndex = uncheckedCount + 1 + index // +1 für Separator
|
||||
DraggableChecklistItem(
|
||||
item = item,
|
||||
onTextChange = { onTextChange(item.id, it) },
|
||||
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||
onDelete = { onDelete(item.id) },
|
||||
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||
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)
|
||||
)
|
||||
visualIndex = visualIndex,
|
||||
dragDropState = dragDropState,
|
||||
focusNewItemId = focusNewItemId,
|
||||
onTextChange = onTextChange,
|
||||
onCheckedChange = onCheckedChange,
|
||||
onDelete = onDelete,
|
||||
onAddNewItemAfter = onAddNewItemAfter,
|
||||
onFocusHandled = onFocusHandled,
|
||||
onHeightChanged = { scrollToItemIndex = visualIndex } // 🆕 v1.8.1 (IMPL_05)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
@@ -90,64 +91,92 @@ class NoteEditorViewModel(
|
||||
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||
|
||||
if (noteId != null) {
|
||||
// Load existing note
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let { note ->
|
||||
currentNoteType = note.noteType
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
noteType = note.noteType,
|
||||
isNewNote = false,
|
||||
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
|
||||
ToolbarTitle.EDIT_CHECKLIST
|
||||
} else {
|
||||
ToolbarTitle.EDIT_NOTE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (note.noteType == NoteType.CHECKLIST) {
|
||||
val items = note.checklistItems?.sortedBy { it.order }?.map {
|
||||
ChecklistItemState(
|
||||
id = it.id,
|
||||
text = it.text,
|
||||
isChecked = it.isChecked,
|
||||
order = it.order
|
||||
)
|
||||
} ?: emptyList()
|
||||
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
|
||||
_checklistItems.value = sortChecklistItems(items)
|
||||
}
|
||||
}
|
||||
loadExistingNote(noteId)
|
||||
} else {
|
||||
// New note
|
||||
currentNoteType = try {
|
||||
NoteType.valueOf(noteTypeString)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
initNewNote(noteTypeString)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadExistingNote(noteId: String) {
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let { note ->
|
||||
currentNoteType = note.noteType
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
noteType = currentNoteType,
|
||||
isNewNote = true,
|
||||
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
|
||||
ToolbarTitle.NEW_CHECKLIST
|
||||
title = note.title,
|
||||
content = note.content,
|
||||
noteType = note.noteType,
|
||||
isNewNote = false,
|
||||
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
|
||||
ToolbarTitle.EDIT_CHECKLIST
|
||||
} else {
|
||||
ToolbarTitle.NEW_NOTE
|
||||
ToolbarTitle.EDIT_NOTE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Add first empty item for new checklists
|
||||
if (currentNoteType == NoteType.CHECKLIST) {
|
||||
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
|
||||
if (note.noteType == NoteType.CHECKLIST) {
|
||||
loadChecklistData(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChecklistData(note: Note) {
|
||||
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
|
||||
note.checklistSortOption?.let { sortName ->
|
||||
_lastChecklistSortOption.value = parseSortOption(sortName)
|
||||
}
|
||||
|
||||
val items = note.checklistItems?.sortedBy { it.order }?.map {
|
||||
ChecklistItemState(
|
||||
id = it.id,
|
||||
text = it.text,
|
||||
isChecked = it.isChecked,
|
||||
order = it.order
|
||||
)
|
||||
} ?: emptyList()
|
||||
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
|
||||
_checklistItems.value = sortChecklistItems(items)
|
||||
}
|
||||
|
||||
private fun initNewNote(noteTypeString: String) {
|
||||
currentNoteType = try {
|
||||
NoteType.valueOf(noteTypeString)
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
noteType = currentNoteType,
|
||||
isNewNote = true,
|
||||
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
|
||||
ToolbarTitle.NEW_CHECKLIST
|
||||
} else {
|
||||
ToolbarTitle.NEW_NOTE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Add first empty item for new checklists
|
||||
if (currentNoteType == NoteType.CHECKLIST) {
|
||||
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a ChecklistSortOption from its string name.
|
||||
* Falls back to MANUAL if the name is unknown (e.g., from older app versions).
|
||||
*/
|
||||
private fun parseSortOption(sortName: String): ChecklistSortOption {
|
||||
return try {
|
||||
ChecklistSortOption.valueOf(sortName)
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Unknown sort option '$sortName', using MANUAL")
|
||||
ChecklistSortOption.MANUAL
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Actions
|
||||
@@ -173,11 +202,28 @@ class NoteEditorViewModel(
|
||||
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
|
||||
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
|
||||
*/
|
||||
/**
|
||||
* Sortiert Checklist-Items basierend auf der aktuellen Sortier-Option.
|
||||
* 🆕 v1.8.1 (IMPL_03-FIX): Berücksichtigt jetzt _lastChecklistSortOption
|
||||
* anstatt immer unchecked-first zu sortieren.
|
||||
*/
|
||||
private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
|
||||
val unchecked = items.filter { !it.isChecked }
|
||||
val checked = items.filter { it.isChecked }
|
||||
val sorted = when (_lastChecklistSortOption.value) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val unchecked = items.filter { !it.isChecked }
|
||||
val checked = items.filter { it.isChecked }
|
||||
unchecked + checked
|
||||
}
|
||||
ChecklistSortOption.CHECKED_FIRST ->
|
||||
items.sortedByDescending { it.isChecked }
|
||||
ChecklistSortOption.ALPHABETICAL_ASC ->
|
||||
items.sortedBy { it.text.lowercase() }
|
||||
ChecklistSortOption.ALPHABETICAL_DESC ->
|
||||
items.sortedByDescending { it.text.lowercase() }
|
||||
}
|
||||
|
||||
return (unchecked + checked).mapIndexed { index, item ->
|
||||
return sorted.mapIndexed { index, item ->
|
||||
item.copy(order = index)
|
||||
}
|
||||
}
|
||||
@@ -198,13 +244,34 @@ class NoteEditorViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
|
||||
*
|
||||
* Guard: Bei MANUAL/UNCHECKED_FIRST wird sichergestellt, dass das neue (unchecked)
|
||||
* Item nicht innerhalb der checked-Sektion eingefügt wird. Falls das Trigger-Item
|
||||
* checked ist, wird stattdessen vor dem ersten checked Item eingefügt.
|
||||
*/
|
||||
fun addChecklistItemAfter(afterItemId: String): String {
|
||||
val newItem = ChecklistItemState.createEmpty(0)
|
||||
_checklistItems.update { items ->
|
||||
val index = items.indexOfFirst { it.id == afterItemId }
|
||||
if (index >= 0) {
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
val hasSeparator = currentSort == ChecklistSortOption.MANUAL ||
|
||||
currentSort == ChecklistSortOption.UNCHECKED_FIRST
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_15): Wenn das Trigger-Item checked ist und ein Separator
|
||||
// existiert, darf das neue unchecked Item nicht in die checked-Sektion.
|
||||
// → Stattdessen vor dem ersten checked Item einfügen.
|
||||
val effectiveIndex = if (hasSeparator && items[index].isChecked) {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else index + 1
|
||||
} else {
|
||||
index + 1
|
||||
}
|
||||
|
||||
val newList = items.toMutableList()
|
||||
newList.add(index + 1, newItem)
|
||||
newList.add(effectiveIndex, newItem)
|
||||
// Update order values
|
||||
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
} else {
|
||||
@@ -213,12 +280,46 @@ class NoteEditorViewModel(
|
||||
}
|
||||
return newItem.id
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item an der semantisch korrekten Position ein.
|
||||
*
|
||||
* Bei MANUAL/UNCHECKED_FIRST: Vor dem ersten checked Item (= direkt über dem Separator).
|
||||
* Bei allen anderen Modi: Am Ende der Liste (kein Separator sichtbar).
|
||||
*
|
||||
* Verhindert, dass checked Items über den Separator springen oder das neue Item
|
||||
* unter dem Separator erscheint.
|
||||
*/
|
||||
fun addChecklistItemAtEnd(): String {
|
||||
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
|
||||
_checklistItems.update { items -> items + newItem }
|
||||
val newItem = ChecklistItemState.createEmpty(0)
|
||||
_checklistItems.update { items ->
|
||||
val insertIndex = calculateInsertIndexForNewItem(items)
|
||||
val newList = items.toMutableList()
|
||||
newList.add(insertIndex, newItem)
|
||||
newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
}
|
||||
return newItem.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_15): Berechnet die korrekte Insert-Position für ein neues unchecked Item.
|
||||
*
|
||||
* - MANUAL / UNCHECKED_FIRST: Vor dem ersten checked Item (direkt über dem Separator)
|
||||
* - Alle anderen Modi: Am Ende der Liste (kein Separator, kein visuelles Problem)
|
||||
*
|
||||
* Falls keine checked Items existieren, wird am Ende eingefügt.
|
||||
*/
|
||||
private fun calculateInsertIndexForNewItem(items: List<ChecklistItemState>): Int {
|
||||
val currentSort = _lastChecklistSortOption.value
|
||||
return when (currentSort) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
|
||||
}
|
||||
else -> items.size
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChecklistItem(itemId: String) {
|
||||
_checklistItems.update { items ->
|
||||
@@ -238,15 +339,18 @@ class NoteEditorViewModel(
|
||||
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 item = mutableList.removeAt(fromIndex)
|
||||
mutableList.add(toIndex, item)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Cross-Boundary Move mit Auto-Toggle
|
||||
// Wenn ein Item die Grenze überschreitet, wird es automatisch checked/unchecked.
|
||||
val movedItem = if (fromItem.isChecked != toItem.isChecked) {
|
||||
item.copy(isChecked = toItem.isChecked)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
|
||||
mutableList.add(toIndex, movedItem)
|
||||
// Update order values
|
||||
mutableList.mapIndexed { index, i -> i.copy(order = index) }
|
||||
}
|
||||
@@ -348,6 +452,7 @@ class NoteEditorViewModel(
|
||||
content = "", // Empty for checklists
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = validItems,
|
||||
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
@@ -357,6 +462,7 @@ class NoteEditorViewModel(
|
||||
content = "",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = validItems,
|
||||
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
|
||||
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
@@ -366,7 +472,7 @@ class NoteEditorViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
|
||||
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
|
||||
|
||||
// 🌟 v1.6.0: Trigger onSave Sync
|
||||
triggerOnSaveSync()
|
||||
@@ -406,17 +512,33 @@ class NoteEditorViewModel(
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
|
||||
if (success) {
|
||||
Logger.d(TAG, "Note $noteId deleted from server")
|
||||
SyncStateManager.showInfo(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Logger.w(TAG, "Failed to delete note $noteId from server")
|
||||
SyncStateManager.showError(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_server_delete_failed
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Error deleting note from server: ${e.message}")
|
||||
SyncStateManager.showError(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_server_error,
|
||||
e.message ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_DELETED))
|
||||
_events.emit(NoteEditorEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
@@ -513,6 +635,7 @@ class NoteEditorViewModel(
|
||||
Logger.d(TAG, "📤 Triggering onSave sync")
|
||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
.addTag(Constants.SYNC_ONSAVE_TAG) // 🆕 v1.8.1 (IMPL_08B): Bypassed globalen Cooldown
|
||||
.build()
|
||||
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import dev.dettmer.simplenotes.R
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items
|
||||
* 🆕 v1.8.1 (IMPL_14): Drag-Awareness — Primary-Farbe während Drag als visueller Hinweis
|
||||
*
|
||||
* Zeigt eine dezente Linie mit Anzahl der erledigten Items:
|
||||
* ── 3 completed ──
|
||||
@@ -22,7 +23,8 @@ import dev.dettmer.simplenotes.R
|
||||
@Composable
|
||||
fun CheckedItemsSeparator(
|
||||
checkedCount: Int,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
isDragActive: Boolean = false // 🆕 v1.8.1 IMPL_14
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -32,7 +34,10 @@ fun CheckedItemsSeparator(
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
color = if (isDragActive)
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -42,13 +47,19 @@ fun CheckedItemsSeparator(
|
||||
checkedCount
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
color = if (isDragActive)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
color = if (isDragActive)
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -71,6 +71,7 @@ fun ChecklistItemRow(
|
||||
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
|
||||
onHeightChanged: (() -> Unit)? = null, // 🆕 v1.8.1: IMPL_05 - Auto-scroll callback
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -92,17 +93,14 @@ fun ChecklistItemRow(
|
||||
// 🆕 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.1: IMPL_05 - Letzte Zeilenanzahl tracken für Auto-Scroll
|
||||
var lastLineCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
// 🆕 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.8.1: Gradient-Sichtbarkeit direkt berechnet (kein derivedStateOf)
|
||||
// derivedStateOf mit remember{} fängt showGradient als stale val — nie aktualisiert.
|
||||
val showGradient = hasOverflow && collapsedHeightDp != null && !isFocused && !isAnyItemDragging
|
||||
val showTopGradient = showGradient && scrollState.value > 0
|
||||
val showBottomGradient = showGradient && scrollState.value < scrollState.maxValue
|
||||
|
||||
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
||||
LaunchedEffect(requestFocus) {
|
||||
@@ -173,7 +171,7 @@ fun ChecklistItemRow(
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
|
||||
Box(
|
||||
modifier = if (!isFocused && useScrollClipping) {
|
||||
modifier = if (!isFocused && hasOverflow && collapsedHeightDp != null) {
|
||||
Modifier
|
||||
.heightIn(max = collapsedHeightDp!!)
|
||||
.verticalScroll(scrollState)
|
||||
@@ -216,13 +214,16 @@ fun ChecklistItemRow(
|
||||
onNext = { onAddNewItem() }
|
||||
),
|
||||
singleLine = false,
|
||||
// maxLines nur als Fallback bis collapsedHeight berechnet ist
|
||||
maxLines = if (isFocused || useScrollClipping) Int.MAX_VALUE else COLLAPSED_MAX_LINES,
|
||||
// 🆕 v1.8.1: maxLines IMMER Int.MAX_VALUE — keine Oszillation möglich
|
||||
// Höhenbegrenzung erfolgt ausschließlich über heightIn-Modifier oben.
|
||||
// Vorher: maxLines=5 → lineCount gedeckelt → Overflow nie erkannt → Deadlock
|
||||
maxLines = Int.MAX_VALUE,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
onTextLayout = { textLayoutResult ->
|
||||
// 🆕 v1.8.0: Overflow erkennen - ABER NUR wenn kein Drag aktiv ist
|
||||
// 🆕 v1.8.1: lineCount ist jetzt akkurat (maxLines=MAX_VALUE deckelt nicht)
|
||||
val lineCount = textLayoutResult.lineCount
|
||||
if (!isAnyItemDragging) {
|
||||
val overflow = textLayoutResult.lineCount > COLLAPSED_MAX_LINES
|
||||
val overflow = lineCount > COLLAPSED_MAX_LINES
|
||||
hasOverflow = overflow
|
||||
// Höhe der ersten 5 Zeilen berechnen (einmalig)
|
||||
if (overflow && collapsedHeightDp == null) {
|
||||
@@ -230,7 +231,16 @@ fun ChecklistItemRow(
|
||||
textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp()
|
||||
}
|
||||
}
|
||||
// Reset wenn Text gekürzt wird
|
||||
if (!overflow) {
|
||||
collapsedHeightDp = null
|
||||
}
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_05): Höhenänderung bei Zeilenumbruch melden
|
||||
if (isFocused && lineCount > lastLineCount && lastLineCount > 0) {
|
||||
onHeightChanged?.invoke()
|
||||
}
|
||||
lastLineCount = lineCount
|
||||
},
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
|
||||
@@ -44,11 +44,12 @@ import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* Main Activity with Jetpack Compose UI
|
||||
* v1.5.0: Complete MainActivity Redesign with Compose
|
||||
*
|
||||
*
|
||||
* Replaces the old 805-line MainActivity.kt with a modern
|
||||
* Compose-based implementation featuring:
|
||||
* - Notes list with swipe-to-delete
|
||||
@@ -58,22 +59,22 @@ import kotlinx.coroutines.launch
|
||||
* - Design consistent with ComposeSettingsActivity
|
||||
*/
|
||||
class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ComposeMainActivity"
|
||||
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
}
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
||||
|
||||
private val viewModel: MainViewModel by viewModel()
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
|
||||
// Phase 3: Track if coming from editor to scroll to top
|
||||
private var cameFromEditor = false
|
||||
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
|
||||
*/
|
||||
@@ -81,9 +82,9 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val success = intent?.getBooleanExtra("success", false) ?: false
|
||||
val count = intent?.getIntExtra("count", 0) ?: 0
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
|
||||
|
||||
|
||||
// UI refresh
|
||||
if (success && count > 0) {
|
||||
viewModel.loadNotes()
|
||||
@@ -91,60 +92,60 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install Splash Screen (Android 12+)
|
||||
installSplashScreen()
|
||||
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
// Apply Dynamic Colors for Material You (Android 12+)
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
|
||||
// Enable edge-to-edge display
|
||||
enableEdgeToEdge()
|
||||
|
||||
|
||||
// Initialize Logger and enable file logging if configured
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
|
||||
// Clear old sync notifications on app start
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
|
||||
// Request notification permission (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
|
||||
// Setup Sync State Observer
|
||||
setupSyncStateObserver()
|
||||
|
||||
|
||||
setContent {
|
||||
SimpleNotesTheme {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
// Dialog state for delete confirmation
|
||||
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
|
||||
|
||||
|
||||
// Handle delete dialog events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showDeleteDialog.collect { data ->
|
||||
deleteDialogData = data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle toast events
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showToast.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete confirmation dialog
|
||||
deleteDialogData?.let { data ->
|
||||
DeleteConfirmationDialog(
|
||||
@@ -163,70 +164,70 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenNote = { noteId -> openNoteEditor(noteId) },
|
||||
onOpenSettings = { openSettings() },
|
||||
onCreateNote = { noteType -> createNote(noteType) }
|
||||
)
|
||||
|
||||
|
||||
// v1.8.0: Post-Update Changelog (shows once after update)
|
||||
UpdateChangelogSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
||||
|
||||
|
||||
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
||||
// This ensures UI reflects current offline mode when returning from Settings
|
||||
viewModel.refreshOfflineModeState()
|
||||
|
||||
|
||||
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
||||
viewModel.refreshDisplayMode()
|
||||
|
||||
|
||||
// Register BroadcastReceiver for Background-Sync
|
||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
)
|
||||
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
|
||||
// Reload notes
|
||||
viewModel.loadNotes()
|
||||
|
||||
|
||||
// Phase 3: Scroll to top if coming from editor (new/edited note)
|
||||
if (cameFromEditor) {
|
||||
viewModel.scrollToTop()
|
||||
cameFromEditor = false
|
||||
Logger.d(TAG, "📜 Came from editor - scrolling to top")
|
||||
}
|
||||
|
||||
|
||||
// Trigger Auto-Sync on app resume
|
||||
viewModel.triggerAutoSync("onResume")
|
||||
}
|
||||
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
@Suppress("DEPRECATION")
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
|
||||
|
||||
private fun setupSyncStateObserver() {
|
||||
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
|
||||
SyncStateManager.syncStatus.observe(this) { status ->
|
||||
viewModel.updateSyncState(status)
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
|
||||
lifecycleScope.launch {
|
||||
SyncStateManager.syncProgress.collect { progress ->
|
||||
@@ -236,6 +237,11 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
kotlinx.coroutines.delay(2000L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_12): INFO-Meldungen nach 2.5s ausblenden
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.INFO -> {
|
||||
kotlinx.coroutines.delay(2500L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
|
||||
kotlinx.coroutines.delay(4000L)
|
||||
SyncStateManager.reset()
|
||||
@@ -245,14 +251,14 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun openNoteEditor(noteId: String?) {
|
||||
cameFromEditor = true
|
||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||
noteId?.let {
|
||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||
}
|
||||
|
||||
|
||||
// v1.5.0: Add slide animation
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
@@ -261,12 +267,12 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
|
||||
private fun createNote(noteType: NoteType) {
|
||||
cameFromEditor = true
|
||||
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||
|
||||
|
||||
// v1.5.0: Add slide animation
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
@@ -275,7 +281,7 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = Intent(this, ComposeSettingsActivity::class.java)
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
@@ -286,10 +292,10 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
|
||||
}
|
||||
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
@@ -298,29 +304,29 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.1: Migrates existing checklists for backwards compatibility.
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
|
||||
// Only run once
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val storage = NotesStorage(this)
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = SyncStatus.PENDING
|
||||
@@ -328,24 +334,24 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
|
||||
// Mark migration as done
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
|
||||
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
|
||||
// Settings changed, reload notes
|
||||
viewModel.loadNotes()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -354,15 +360,15 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
|
||||
when (requestCode) {
|
||||
REQUEST_NOTIFICATION_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() &&
|
||||
if (grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this,
|
||||
getString(R.string.toast_notifications_disabled),
|
||||
Toast.makeText(this,
|
||||
getString(R.string.toast_notifications_disabled),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
@@ -384,8 +390,8 @@ private fun DeleteConfirmationDialog(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||
text = {
|
||||
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
|
||||
@@ -59,13 +59,14 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
||||
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
|
||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Main screen displaying the notes list
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
*
|
||||
*
|
||||
* Performance optimized with proper state handling:
|
||||
* - LazyListState for scroll control
|
||||
* - Scaffold FAB slot for proper z-ordering
|
||||
@@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel,
|
||||
viewModel: MainViewModel = koinViewModel(),
|
||||
onOpenNote: (String?) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateNote: (NoteType) -> Unit
|
||||
@@ -82,37 +83,37 @@ fun MainScreen(
|
||||
val notes by viewModel.sortedNotes.collectAsState()
|
||||
val syncState by viewModel.syncState.collectAsState()
|
||||
val scrollToTop by viewModel.scrollToTop.collectAsState()
|
||||
|
||||
|
||||
// 🆕 v1.8.0: Einziges Banner-System
|
||||
val syncProgress by viewModel.syncProgress.collectAsState()
|
||||
|
||||
|
||||
// Multi-Select State
|
||||
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||
|
||||
|
||||
// 🌟 v1.6.0: Reactive offline mode state
|
||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||
|
||||
|
||||
// 🎨 v1.7.0: Display mode (list or grid)
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
|
||||
|
||||
// Delete confirmation dialog state
|
||||
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 scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||
val gridState = rememberLazyStaggeredGridState()
|
||||
|
||||
|
||||
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
|
||||
var timestampTicker by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -121,17 +122,17 @@ fun MainScreen(
|
||||
timestampTicker = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Compute isSyncing once
|
||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||
|
||||
|
||||
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
|
||||
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
|
||||
// which is called in ComposeMainActivity.onResume() when returning from Settings
|
||||
val hasServerConfig = viewModel.hasServerConfig()
|
||||
val isSyncAvailable = !isOfflineMode && hasServerConfig
|
||||
val canSync = isSyncAvailable && !isSyncing
|
||||
|
||||
|
||||
// Handle snackbar events from ViewModel
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showSnackbar.collect { data ->
|
||||
@@ -147,7 +148,7 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Phase 3: Scroll to top when new note created
|
||||
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
||||
LaunchedEffect(scrollToTop) {
|
||||
@@ -160,7 +161,7 @@ fun MainScreen(
|
||||
viewModel.resetScrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -213,7 +214,7 @@ fun MainScreen(
|
||||
progress = syncProgress,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
|
||||
// Content: Empty state or notes list
|
||||
if (notes.isEmpty()) {
|
||||
EmptyState(modifier = Modifier.weight(1f))
|
||||
@@ -249,7 +250,7 @@ fun MainScreen(
|
||||
listState = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
onNoteClick = { note -> onOpenNote(note.id) },
|
||||
onNoteLongPress = { note ->
|
||||
onNoteLongPress = { note ->
|
||||
// Long-press starts selection mode
|
||||
viewModel.startSelectionMode(note.id)
|
||||
},
|
||||
@@ -260,7 +261,7 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
||||
AnimatedVisibility(
|
||||
visible = !isSelectionMode,
|
||||
@@ -277,7 +278,7 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Batch Delete Confirmation Dialog
|
||||
if (showBatchDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
@@ -294,14 +295,14 @@ fun MainScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.0: Sync Status Legend Dialog
|
||||
if (showSyncLegend) {
|
||||
SyncStatusLegendDialog(
|
||||
onDismiss = { showSyncLegend = false }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 🔀 v1.8.0: Sort Dialog
|
||||
if (showSortDialog) {
|
||||
SortDialog(
|
||||
@@ -344,7 +345,7 @@ private fun MainTopBar(
|
||||
contentDescription = stringResource(R.string.sort_notes)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
|
||||
if (showSyncLegend) {
|
||||
IconButton(onClick = onSyncLegendClick) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package dev.dettmer.simplenotes.ui.main
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.SortDirection
|
||||
@@ -31,50 +33,50 @@ import kotlinx.coroutines.withContext
|
||||
/**
|
||||
* ViewModel for MainActivity Compose
|
||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||
*
|
||||
*
|
||||
* Manages notes list, sync state, and deletion with undo.
|
||||
*/
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
class MainViewModel(
|
||||
private val storage: NotesStorage,
|
||||
private val prefs: SharedPreferences
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainViewModel"
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(application)
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||
|
||||
|
||||
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
|
||||
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Multi-Select State (v1.5.0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
|
||||
|
||||
|
||||
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Offline Mode State (reactive)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _isOfflineMode = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
)
|
||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||
|
||||
|
||||
/**
|
||||
* Refresh offline mode state from SharedPreferences
|
||||
* Called when returning from Settings screen (in onResume)
|
||||
@@ -85,16 +87,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_isOfflineMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Mode State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _displayMode = MutableStateFlow(
|
||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
)
|
||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||
|
||||
|
||||
/**
|
||||
* Refresh display mode from SharedPreferences
|
||||
* Called when returning from Settings screen
|
||||
@@ -104,25 +106,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_displayMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue")
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🔀 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.
|
||||
@@ -138,68 +140,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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)
|
||||
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UI Events
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private val _showToast = MutableSharedFlow<String>()
|
||||
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
|
||||
|
||||
|
||||
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
|
||||
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
|
||||
|
||||
|
||||
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
|
||||
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
|
||||
|
||||
|
||||
// Phase 3: Scroll-to-top when new note is created
|
||||
private val _scrollToTop = MutableStateFlow(false)
|
||||
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
|
||||
|
||||
|
||||
// Track first note ID to detect new notes
|
||||
private var previousFirstNoteId: String? = null
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Data Classes
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
data class DeleteDialogData(
|
||||
val note: Note,
|
||||
val originalList: List<Note>
|
||||
)
|
||||
|
||||
|
||||
data class SnackbarData(
|
||||
val message: String,
|
||||
val actionLabel: String,
|
||||
val onAction: () -> Unit
|
||||
)
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Initialization
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
init {
|
||||
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadNotesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Notes Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
/**
|
||||
* Load notes asynchronously on IO dispatcher
|
||||
* This prevents UI blocking during app startup
|
||||
@@ -207,24 +209,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private suspend fun loadNotesAsync() {
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val pendingIds = _pendingDeletions.value
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }
|
||||
|
||||
val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
|
||||
id = it.id,
|
||||
content = it.content
|
||||
) }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Phase 3: Detect if a new note was added at the top
|
||||
val newFirstNoteId = filteredNotes.firstOrNull()?.id
|
||||
if (newFirstNoteId != null &&
|
||||
previousFirstNoteId != null &&
|
||||
if (newFirstNoteId != null &&
|
||||
previousFirstNoteId != null &&
|
||||
newFirstNoteId != previousFirstNoteId) {
|
||||
// New note at top → trigger scroll
|
||||
_scrollToTop.value = true
|
||||
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
|
||||
}
|
||||
previousFirstNoteId = newFirstNoteId
|
||||
|
||||
|
||||
_notes.value = filteredNotes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public loadNotes - delegates to async version
|
||||
*/
|
||||
@@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
loadNotesAsync()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset scroll-to-top flag after scroll completed
|
||||
*/
|
||||
fun resetScrollToTop() {
|
||||
_scrollToTop.value = false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Force scroll to top (e.g., after returning from editor)
|
||||
*/
|
||||
fun scrollToTop() {
|
||||
_scrollToTop.value = true
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Multi-Select Actions (v1.5.0)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
/**
|
||||
* Toggle selection of a note
|
||||
*/
|
||||
@@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_selectedNotes.value + noteId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start selection mode with initial note
|
||||
*/
|
||||
fun startSelectionMode(noteId: String) {
|
||||
_selectedNotes.value = setOf(noteId)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Select all notes
|
||||
*/
|
||||
fun selectAllNotes() {
|
||||
_selectedNotes.value = _notes.value.map { it.id }.toSet()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear selection and exit selection mode
|
||||
*/
|
||||
fun clearSelection() {
|
||||
_selectedNotes.value = emptySet()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get count of selected notes
|
||||
*/
|
||||
fun getSelectedCount(): Int = _selectedNotes.value.size
|
||||
|
||||
|
||||
/**
|
||||
* Delete all selected notes
|
||||
*/
|
||||
fun deleteSelectedNotes(deleteFromServer: Boolean) {
|
||||
val selectedIds = _selectedNotes.value.toList()
|
||||
val selectedNotes = _notes.value.filter { it.id in selectedIds }
|
||||
|
||||
|
||||
if (selectedNotes.isEmpty()) return
|
||||
|
||||
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
|
||||
|
||||
|
||||
// Delete from storage
|
||||
selectedNotes.forEach { note ->
|
||||
storage.deleteNote(note.id)
|
||||
}
|
||||
|
||||
|
||||
// Clear selection
|
||||
clearSelection()
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Show snackbar with undo
|
||||
val count = selectedNotes.size
|
||||
val message = if (deleteFromServer) {
|
||||
@@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else {
|
||||
getString(R.string.snackbar_notes_deleted_local, count)
|
||||
}
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = message,
|
||||
@@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
undoDeleteMultiple(selectedNotes)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||
// If delete from server, actually delete after a short delay
|
||||
// (to allow undo action before server deletion)
|
||||
@@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Undo deletion of multiple notes
|
||||
*/
|
||||
private fun undoDeleteMultiple(notes: List<Note>) {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
|
||||
|
||||
|
||||
// Restore to storage
|
||||
notes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
}
|
||||
@@ -370,10 +375,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
fun onNoteLongPressDelete(note: Note) {
|
||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||
|
||||
|
||||
// Store original list for potential restore
|
||||
val originalList = _notes.value.toList()
|
||||
|
||||
|
||||
if (alwaysDeleteFromServer) {
|
||||
// Auto-delete without dialog
|
||||
deleteNoteConfirmed(note, deleteFromServer = true)
|
||||
@@ -392,34 +397,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun onNoteSwipedToDelete(note: Note) {
|
||||
onNoteLongPressDelete(note) // Delegate to long-press handler
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Restore note after swipe (user cancelled dialog)
|
||||
*/
|
||||
fun restoreNoteAfterSwipe(originalList: List<Note>) {
|
||||
_notes.value = originalList
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Confirm note deletion (from dialog or auto-delete)
|
||||
*/
|
||||
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
|
||||
// Add to pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value + note.id
|
||||
|
||||
|
||||
// Delete from storage
|
||||
storage.deleteNote(note.id)
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
|
||||
|
||||
// Show snackbar with undo
|
||||
val message = if (deleteFromServer) {
|
||||
getString(R.string.snackbar_note_deleted_server, note.title)
|
||||
} else {
|
||||
getString(R.string.snackbar_note_deleted_local, note.title)
|
||||
}
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = message,
|
||||
@@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
undoDelete(note)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing
|
||||
// If delete from server, actually delete after snackbar timeout
|
||||
if (deleteFromServer) {
|
||||
@@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Undo note deletion
|
||||
*/
|
||||
fun undoDelete(note: Note) {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - note.id
|
||||
|
||||
|
||||
// Restore to storage
|
||||
storage.saveNote(note)
|
||||
|
||||
|
||||
// Reload notes
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Actually delete note from server after snackbar dismissed
|
||||
*/
|
||||
@@ -468,21 +473,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
|
||||
|
||||
if (success) {
|
||||
_showToast.emit(getString(R.string.snackbar_deleted_from_server))
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
|
||||
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
|
||||
} else {
|
||||
_showToast.emit(getString(R.string.snackbar_server_delete_failed))
|
||||
SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: ""))
|
||||
SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
|
||||
} finally {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete multiple notes from server with aggregated toast
|
||||
* Shows single toast at the end instead of one per note
|
||||
@@ -492,7 +498,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val webdavService = WebDavSyncService(getApplication())
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
|
||||
noteIds.forEach { noteId ->
|
||||
try {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
@@ -506,8 +512,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
}
|
||||
|
||||
// Show aggregated toast
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
|
||||
val message = when {
|
||||
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
||||
successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
|
||||
@@ -517,25 +523,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
successCount + failCount
|
||||
)
|
||||
}
|
||||
_showToast.emit(message)
|
||||
if (failCount > 0) {
|
||||
SyncStateManager.showError(message)
|
||||
} else {
|
||||
SyncStateManager.showInfo(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finalize deletion (remove from pending set)
|
||||
*/
|
||||
fun finalizeDeletion(noteId: String) {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
fun updateSyncState(status: SyncStateManager.SyncStatus) {
|
||||
_syncState.value = status.state
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
@@ -554,7 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
|
||||
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
|
||||
val prefs = getApplication<android.app.Application>().getSharedPreferences(
|
||||
Constants.PREFS_NAME,
|
||||
android.content.Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
|
||||
if (!SyncStateManager.tryStartSync(source)) {
|
||||
@@ -570,7 +587,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
|
||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Check for unsynced changes (Banner zeigt bereits PREPARING)
|
||||
@@ -580,23 +600,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
loadNotes()
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Check server reachability
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Perform sync
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
|
||||
if (result.isSuccess) {
|
||||
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
|
||||
val bannerMessage = buildString {
|
||||
@@ -621,7 +641,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trigger auto-sync (onResume)
|
||||
* Only runs if server is configured and interval has passed
|
||||
@@ -635,12 +655,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Throttling check
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
|
||||
if (!SyncStateManager.canSyncGlobally(prefs)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttling check (eigener 60s-Cooldown für onResume)
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
@@ -652,19 +677,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 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)) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
|
||||
SyncStateManager.markGlobalSyncStarted(prefs)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Check for unsynced changes
|
||||
@@ -673,28 +701,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
SyncStateManager.reset() // Silent → geht direkt auf IDLE
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Check server reachability
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset() // Silent → kein Error-Banner
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// Perform sync
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
// Silent Sync mit echten Änderungen → trotzdem markCompleted (wird silent behandelt)
|
||||
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
|
||||
// Das Banner-System respektiert silent=true korrekt (markCompleted → IDLE)
|
||||
// Toast wurde fälschlicherweise trotzdem angezeigt
|
||||
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
|
||||
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||
loadNotes()
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
@@ -710,25 +739,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun canTriggerAutoSync(): Boolean {
|
||||
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastSyncTime
|
||||
|
||||
|
||||
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🔀 v1.8.0: Sortierung
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
/**
|
||||
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
|
||||
*/
|
||||
@@ -744,13 +773,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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.
|
||||
*/
|
||||
@@ -759,7 +788,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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.
|
||||
*/
|
||||
@@ -768,7 +797,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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.
|
||||
*/
|
||||
@@ -776,16 +805,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val newDirection = _sortDirection.value.toggle()
|
||||
setSortDirection(newDirection)
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
||||
|
||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||
|
||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||
|
||||
|
||||
fun isServerConfigured(): Boolean {
|
||||
// 🌟 v1.6.0: Use reactive offline mode state
|
||||
if (_isOfflineMode.value) {
|
||||
@@ -794,7 +823,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
|
||||
* Used for determining if sync would be available when offline mode is disabled
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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
|
||||
@@ -32,7 +30,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_03): Helper-Funktionen für die Checklisten-Vorschau in Main Activity.
|
||||
*
|
||||
* Stellt sicher, dass die Sortierung aus dem Editor konsistent
|
||||
* in allen Preview-Components (NoteCard, NoteCardCompact, NoteCardGrid)
|
||||
* angezeigt wird.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sortiert Checklist-Items für die Vorschau basierend auf der
|
||||
* gespeicherten Sortier-Option.
|
||||
*/
|
||||
fun sortChecklistItemsForPreview(
|
||||
items: List<ChecklistItem>,
|
||||
sortOptionName: String?
|
||||
): List<ChecklistItem> {
|
||||
val sortOption = try {
|
||||
sortOptionName?.let { ChecklistSortOption.valueOf(it) }
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
null
|
||||
} ?: ChecklistSortOption.MANUAL
|
||||
|
||||
return when (sortOption) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST ->
|
||||
items.sortedBy { it.isChecked }
|
||||
|
||||
ChecklistSortOption.CHECKED_FIRST ->
|
||||
items.sortedByDescending { it.isChecked }
|
||||
|
||||
ChecklistSortOption.ALPHABETICAL_ASC ->
|
||||
items.sortedBy { it.text.lowercase() }
|
||||
|
||||
ChecklistSortOption.ALPHABETICAL_DESC ->
|
||||
items.sortedByDescending { it.text.lowercase() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert den Vorschau-Text für eine Checkliste mit korrekter
|
||||
* Sortierung und passenden Emojis.
|
||||
*
|
||||
* @param items Die Checklisten-Items
|
||||
* @param sortOptionName Der Name der ChecklistSortOption (oder null für MANUAL)
|
||||
* @return Formatierter Preview-String mit Emojis und Zeilenumbrüchen
|
||||
*
|
||||
* 🆕 v1.8.1 (IMPL_06): Emoji-Änderung (☑️ statt ✅ für checked items)
|
||||
*/
|
||||
fun generateChecklistPreview(
|
||||
items: List<ChecklistItem>,
|
||||
sortOptionName: String?
|
||||
): String {
|
||||
val sorted = sortChecklistItemsForPreview(items, sortOptionName)
|
||||
return sorted.joinToString("\n") { item ->
|
||||
val prefix = if (item.isChecked) "☑️" else "☐"
|
||||
"$prefix ${item.text}"
|
||||
}
|
||||
}
|
||||
@@ -149,11 +149,10 @@ fun NoteCardCompact(
|
||||
text = when (note.noteType) {
|
||||
NoteType.TEXT -> note.content
|
||||
NoteType.CHECKLIST -> {
|
||||
note.checklistItems
|
||||
?.joinToString("\n") { item ->
|
||||
val prefix = if (item.isChecked) "✅" else "☐"
|
||||
"$prefix ${item.text}"
|
||||
} ?: ""
|
||||
// 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
|
||||
note.checklistItems?.let { items ->
|
||||
generateChecklistPreview(items, note.checklistSortOption)
|
||||
} ?: ""
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -163,11 +163,10 @@ fun NoteCardGrid(
|
||||
text = when (note.noteType) {
|
||||
NoteType.TEXT -> note.content
|
||||
NoteType.CHECKLIST -> {
|
||||
note.checklistItems
|
||||
?.joinToString("\n") { item ->
|
||||
val prefix = if (item.isChecked) "✅" else "☐"
|
||||
"$prefix ${item.text}"
|
||||
} ?: ""
|
||||
// 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
|
||||
note.checklistItems?.let { items ->
|
||||
generateChecklistPreview(items, note.checklistSortOption)
|
||||
} ?: ""
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -15,6 +15,7 @@ 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.material.icons.outlined.Info
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -51,11 +52,13 @@ fun SyncProgressBanner(
|
||||
// Farbe animiert wechseln je nach State
|
||||
val isError = progress.phase == SyncPhase.ERROR
|
||||
val isCompleted = progress.phase == SyncPhase.COMPLETED
|
||||
val isResult = isError || isCompleted
|
||||
val isInfo = progress.phase == SyncPhase.INFO // 🆕 v1.8.1 (IMPL_12)
|
||||
val isResult = isError || isCompleted || isInfo
|
||||
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isError -> MaterialTheme.colorScheme.errorContainer
|
||||
isInfo -> MaterialTheme.colorScheme.secondaryContainer // 🆕 v1.8.1 (IMPL_12)
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
},
|
||||
label = "bannerColor"
|
||||
@@ -64,6 +67,7 @@ fun SyncProgressBanner(
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isError -> MaterialTheme.colorScheme.onErrorContainer
|
||||
isInfo -> MaterialTheme.colorScheme.onSecondaryContainer // 🆕 v1.8.1 (IMPL_12)
|
||||
else -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
},
|
||||
label = "bannerContentColor"
|
||||
@@ -89,7 +93,7 @@ fun SyncProgressBanner(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Icon: Spinner (aktiv), Checkmark (completed), Error (error)
|
||||
// Icon: Spinner (aktiv), Checkmark (completed), Error (error), Info (info)
|
||||
when {
|
||||
isCompleted -> {
|
||||
Icon(
|
||||
@@ -99,6 +103,14 @@ fun SyncProgressBanner(
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
isInfo -> { // 🆕 v1.8.1 (IMPL_12)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
isError -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ErrorOutline,
|
||||
@@ -187,5 +199,6 @@ private fun phaseToString(phase: SyncPhase): String {
|
||||
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)
|
||||
SyncPhase.INFO -> "" // 🆕 v1.8.1 (IMPL_12): INFO nutzt immer resultMessage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,4 +82,11 @@ object Constants {
|
||||
|
||||
// 📋 v1.8.0: Post-Update Changelog
|
||||
const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version"
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (über alle Trigger hinweg)
|
||||
const val KEY_LAST_GLOBAL_SYNC_TIME = "last_global_sync_timestamp"
|
||||
const val MIN_GLOBAL_SYNC_INTERVAL_MS = 30_000L // 30 Sekunden
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_08B): onSave-Sync Worker-Tag (bypassed globalen Cooldown)
|
||||
const val SYNC_ONSAVE_TAG = "onsave"
|
||||
}
|
||||
|
||||
@@ -36,15 +36,20 @@ 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
|
||||
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_SCROLL = DpSize(110.dp, 150.dp) // 🆕 v1.8.1: Schmal+scroll (Standard 3x2)
|
||||
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_SCROLL = DpSize(250.dp, 150.dp) // 🆕 v1.8.1: Breit+scroll (Standard 3x2 breit)
|
||||
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)
|
||||
setOf(
|
||||
SIZE_SMALL, SIZE_NARROW_MEDIUM, SIZE_NARROW_SCROLL, SIZE_NARROW_LARGE,
|
||||
SIZE_WIDE_MEDIUM, SIZE_WIDE_SCROLL, SIZE_WIDE_LARGE
|
||||
)
|
||||
)
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.ChecklistSortOption
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
@@ -51,14 +52,32 @@ class ToggleChecklistItemAction : ActionCallback {
|
||||
} else item
|
||||
} ?: return
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_04): Auto-Sort nach Toggle
|
||||
// Konsistent mit NoteEditorViewModel.updateChecklistItemChecked
|
||||
val sortOption = try {
|
||||
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
|
||||
?: ChecklistSortOption.MANUAL
|
||||
|
||||
val sortedItems = if (sortOption == ChecklistSortOption.MANUAL ||
|
||||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) {
|
||||
val unchecked = updatedItems.filter { !it.isChecked }
|
||||
val checked = updatedItems.filter { it.isChecked }
|
||||
(unchecked + checked).mapIndexed { index, item ->
|
||||
item.copy(order = index)
|
||||
}
|
||||
} else {
|
||||
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
|
||||
}
|
||||
|
||||
val updatedNote = note.copy(
|
||||
checklistItems = updatedItems,
|
||||
checklistItems = sortedItems,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, "Toggled checklist item '$itemId' in widget")
|
||||
Logger.d(TAG, "Toggled + auto-sorted checklist item '$itemId' in widget")
|
||||
|
||||
// 🐛 FIX: Glance-State ändern um Re-Render zu erzwingen
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
|
||||
@@ -38,9 +38,11 @@ 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.ChecklistSortOption
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
|
||||
import dev.dettmer.simplenotes.ui.main.components.sortChecklistItemsForPreview
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0: Glance Composable Content für das Notiz-Widget
|
||||
@@ -52,6 +54,7 @@ import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
|
||||
// ── Size Classification ──
|
||||
|
||||
private val WIDGET_HEIGHT_SMALL_THRESHOLD = 110.dp
|
||||
private val WIDGET_HEIGHT_SCROLL_THRESHOLD = 150.dp // 🆕 v1.8.1: Scrollbare Ansicht
|
||||
private val WIDGET_SIZE_MEDIUM_THRESHOLD = 250.dp
|
||||
|
||||
// 🆕 v1.8.0: Increased preview lengths for better text visibility
|
||||
@@ -59,11 +62,39 @@ 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
|
||||
height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL
|
||||
|
||||
// 🆕 v1.8.1: Neue ScrollView-Schwelle bei 150dp Höhe
|
||||
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.NARROW_MED
|
||||
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_SCROLL
|
||||
width < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_TALL
|
||||
|
||||
height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.WIDE_MED
|
||||
height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_SCROLL
|
||||
else -> WidgetSizeClass.WIDE_TALL
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 (IMPL_04): Separator zwischen erledigten und unerledigten Items im Widget.
|
||||
* Glance-kompatible Version von CheckedItemsSeparator.
|
||||
*/
|
||||
@Composable
|
||||
private fun WidgetCheckedItemsSeparator(checkedCount: Int) {
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "── $checkedCount ✔ ──",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.outline,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -177,14 +208,28 @@ fun NoteWidgetContent(
|
||||
}
|
||||
}
|
||||
|
||||
WidgetSizeClass.NARROW_TALL -> Box(modifier = contentClickModifier) {
|
||||
// 🆕 v1.8.1 (IMPL_09): Scrollbare Größe (150dp+ Höhe)
|
||||
WidgetSizeClass.NARROW_SCROLL,
|
||||
WidgetSizeClass.NARROW_TALL -> {
|
||||
when (note.noteType) {
|
||||
NoteType.TEXT -> TextNoteFullView(note)
|
||||
NoteType.CHECKLIST -> ChecklistFullView(
|
||||
note = note,
|
||||
isLocked = isLocked,
|
||||
glanceId = glanceId
|
||||
)
|
||||
NoteType.TEXT -> Box(modifier = contentClickModifier) {
|
||||
TextNoteFullView(note)
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
// 🆕 v1.8.1: Locked: Click -> Options | Unlocked: kein Click -> Scroll frei
|
||||
val checklistBoxModifier = if (isLocked) {
|
||||
contentClickModifier
|
||||
} else {
|
||||
GlanceModifier.fillMaxSize()
|
||||
}
|
||||
Box(modifier = checklistBoxModifier) {
|
||||
ChecklistFullView(
|
||||
note = note,
|
||||
isLocked = isLocked,
|
||||
glanceId = glanceId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,14 +245,28 @@ fun NoteWidgetContent(
|
||||
}
|
||||
}
|
||||
|
||||
WidgetSizeClass.WIDE_TALL -> Box(modifier = contentClickModifier) {
|
||||
// 🆕 v1.8.1 (IMPL_09): Scrollbare Größe (150dp+ Höhe)
|
||||
WidgetSizeClass.WIDE_SCROLL,
|
||||
WidgetSizeClass.WIDE_TALL -> {
|
||||
when (note.noteType) {
|
||||
NoteType.TEXT -> TextNoteFullView(note)
|
||||
NoteType.CHECKLIST -> ChecklistFullView(
|
||||
note = note,
|
||||
isLocked = isLocked,
|
||||
glanceId = glanceId
|
||||
)
|
||||
NoteType.TEXT -> Box(modifier = contentClickModifier) {
|
||||
TextNoteFullView(note)
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
// 🆕 v1.8.1: Locked: Click -> Options | Unlocked: kein Click -> Scroll frei
|
||||
val checklistBoxModifier = if (isLocked) {
|
||||
contentClickModifier
|
||||
} else {
|
||||
GlanceModifier.fillMaxSize()
|
||||
}
|
||||
Box(modifier = checklistBoxModifier) {
|
||||
ChecklistFullView(
|
||||
note = note,
|
||||
isLocked = isLocked,
|
||||
glanceId = glanceId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,13 +429,35 @@ private fun ChecklistCompactView(
|
||||
isLocked: Boolean,
|
||||
glanceId: GlanceId
|
||||
) {
|
||||
val items = note.checklistItems?.sortedBy { it.order } ?: return
|
||||
// 🆕 v1.8.1 (IMPL_04): Sortierung aus Editor übernehmen
|
||||
val items = note.checklistItems?.let { rawItems ->
|
||||
sortChecklistItemsForPreview(rawItems, note.checklistSortOption)
|
||||
} ?: return
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_04): Separator-Logik
|
||||
val uncheckedCount = items.count { !it.isChecked }
|
||||
val checkedCount = items.count { it.isChecked }
|
||||
val sortOption = try {
|
||||
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
|
||||
?: ChecklistSortOption.MANUAL
|
||||
|
||||
val showSeparator = (sortOption == ChecklistSortOption.MANUAL ||
|
||||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) &&
|
||||
uncheckedCount > 0 && checkedCount > 0
|
||||
|
||||
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)) {
|
||||
var separatorShown = false
|
||||
visibleItems.forEach { item ->
|
||||
// 🆕 v1.8.1: Separator vor dem ersten checked Item anzeigen
|
||||
if (showSeparator && !separatorShown && item.isChecked) {
|
||||
WidgetCheckedItemsSeparator(checkedCount = checkedCount)
|
||||
separatorShown = true
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
@@ -385,7 +466,7 @@ private fun ChecklistCompactView(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (item.isChecked) "✅" else "☐",
|
||||
text = if (item.isChecked) "☑️" else "☐", // 🆕 v1.8.1 (IMPL_06)
|
||||
style = TextStyle(fontSize = 14.sp)
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.width(6.dp))
|
||||
@@ -443,15 +524,41 @@ private fun ChecklistFullView(
|
||||
isLocked: Boolean,
|
||||
glanceId: GlanceId
|
||||
) {
|
||||
val items = note.checklistItems?.sortedBy { it.order } ?: return
|
||||
// 🆕 v1.8.1 (IMPL_04): Sortierung aus Editor übernehmen
|
||||
val items = note.checklistItems?.let { rawItems ->
|
||||
sortChecklistItemsForPreview(rawItems, note.checklistSortOption)
|
||||
} ?: return
|
||||
|
||||
// 🆕 v1.8.1 (IMPL_04): Separator-Logik
|
||||
val uncheckedCount = items.count { !it.isChecked }
|
||||
val checkedCount = items.count { it.isChecked }
|
||||
val sortOption = try {
|
||||
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
|
||||
?: ChecklistSortOption.MANUAL
|
||||
|
||||
val showSeparator = (sortOption == ChecklistSortOption.MANUAL ||
|
||||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) &&
|
||||
uncheckedCount > 0 && checkedCount > 0
|
||||
|
||||
// 🆕 v1.8.1: Berechne die Gesamtanzahl der Elemente inklusive Separator
|
||||
val totalItems = items.size + if (showSeparator) 1 else 0
|
||||
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
items(items.size) { index ->
|
||||
val item = items[index]
|
||||
items(totalItems) { index ->
|
||||
// 🆕 v1.8.1: Separator an Position uncheckedCount einfügen
|
||||
if (showSeparator && index == uncheckedCount) {
|
||||
WidgetCheckedItemsSeparator(checkedCount = checkedCount)
|
||||
return@items
|
||||
}
|
||||
|
||||
// Tatsächlichen Item-Index berechnen (nach Separator um 1 verschoben)
|
||||
val itemIndex = if (showSeparator && index > uncheckedCount) index - 1 else index
|
||||
val item = items.getOrNull(itemIndex) ?: return@items
|
||||
|
||||
if (isLocked) {
|
||||
Row(
|
||||
@@ -461,7 +568,7 @@ private fun ChecklistFullView(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (item.isChecked) "✅" else "☐",
|
||||
text = if (item.isChecked) "☑️" else "☐", // 🆕 v1.8.1 (IMPL_06)
|
||||
style = TextStyle(fontSize = 16.sp)
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
@@ -4,11 +4,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.
|
||||
* 🆕 v1.8.1: Added NARROW_SCROLL and WIDE_SCROLL for scrollable mid-size widgets
|
||||
*/
|
||||
enum class WidgetSizeClass {
|
||||
SMALL, // Nur Titel
|
||||
NARROW_MED, // Schmal, Vorschau
|
||||
NARROW_TALL, // Schmal, voller Inhalt
|
||||
WIDE_MED, // Breit, Vorschau
|
||||
WIDE_TALL // Breit, voller Inhalt
|
||||
SMALL, // Nur Titel
|
||||
NARROW_MED, // Schmal, Vorschau (CompactView)
|
||||
NARROW_SCROLL, // 🆕 v1.8.1: Schmal, scrollbare Liste (150dp+)
|
||||
NARROW_TALL, // Schmal, voller Inhalt
|
||||
WIDE_MED, // Breit, Vorschau (CompactView)
|
||||
WIDE_SCROLL, // 🆕 v1.8.1: Breit, scrollbare Liste (150dp+)
|
||||
WIDE_TALL // Breit, voller Inhalt
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import dev.dettmer.simplenotes.models.ChecklistSortOption
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
@@ -174,4 +175,204 @@ class ChecklistSortingTest {
|
||||
assertEquals(1, sorted[1].order)
|
||||
assertEquals(2, sorted[2].order)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_15): Tests für Add-Item Insert-Position
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Simulates calculateInsertIndexForNewItem() from NoteEditorViewModel.
|
||||
* Tests the insert position logic for new unchecked items.
|
||||
*/
|
||||
private fun calculateInsertIndexForNewItem(
|
||||
items: List<ChecklistItemState>,
|
||||
sortOption: ChecklistSortOption
|
||||
): Int {
|
||||
return when (sortOption) {
|
||||
ChecklistSortOption.MANUAL,
|
||||
ChecklistSortOption.UNCHECKED_FIRST -> {
|
||||
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
|
||||
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
|
||||
}
|
||||
else -> items.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the full addChecklistItemAtEnd() logic:
|
||||
* 1. Calculate insert index
|
||||
* 2. Insert new item
|
||||
* 3. Reassign order values
|
||||
*/
|
||||
private fun simulateAddItemAtEnd(
|
||||
items: List<ChecklistItemState>,
|
||||
sortOption: ChecklistSortOption
|
||||
): List<ChecklistItemState> {
|
||||
val newItem = ChecklistItemState(id = "new", text = "", isChecked = false, order = 0)
|
||||
val insertIndex = calculateInsertIndexForNewItem(items, sortOption)
|
||||
val newList = items.toMutableList()
|
||||
newList.add(insertIndex, newItem)
|
||||
return newList.mapIndexed { i, item -> item.copy(order = i) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end inserts before separator in MANUAL mode`() {
|
||||
// Ausgangslage: 2 unchecked, 1 checked (sortiert)
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = false, order = 1),
|
||||
item("c", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
// Neues Item muss an Index 2 stehen (vor dem checked Item)
|
||||
assertEquals(4, result.size)
|
||||
assertEquals("a", result[0].id)
|
||||
assertEquals("b", result[1].id)
|
||||
assertEquals("new", result[2].id) // ← Neues Item VOR Separator
|
||||
assertFalse(result[2].isChecked)
|
||||
assertEquals("c", result[3].id) // ← Checked Item bleibt UNTER Separator
|
||||
assertTrue(result[3].isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end inserts before separator in UNCHECKED_FIRST mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = true, order = 1),
|
||||
item("c", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.UNCHECKED_FIRST)
|
||||
|
||||
assertEquals(4, result.size)
|
||||
assertEquals("a", result[0].id)
|
||||
assertEquals("new", result[1].id) // ← Neues Item direkt nach letztem unchecked
|
||||
assertFalse(result[1].isChecked)
|
||||
assertEquals("b", result[2].id)
|
||||
assertEquals("c", result[3].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end appends at end in CHECKED_FIRST mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = true, order = 0),
|
||||
item("b", checked = false, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.CHECKED_FIRST)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("a", result[0].id)
|
||||
assertEquals("b", result[1].id)
|
||||
assertEquals("new", result[2].id) // ← Am Ende (kein Separator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_ASC mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = true, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_ASC)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[2].id) // ← Am Ende
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_DESC mode`() {
|
||||
val items = listOf(
|
||||
item("a", checked = true, order = 0),
|
||||
item("b", checked = false, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_DESC)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[2].id) // ← Am Ende
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item with no checked items appends at end`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = false, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[2].id) // Kein checked Item → ans Ende
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item with all checked items inserts at position 0`() {
|
||||
val items = listOf(
|
||||
item("a", checked = true, order = 0),
|
||||
item("b", checked = true, order = 1)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("new", result[0].id) // ← Ganz oben (vor allen checked Items)
|
||||
assertFalse(result[0].isChecked)
|
||||
assertEquals("a", result[1].id)
|
||||
assertEquals("b", result[2].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - add item to empty list in MANUAL mode`() {
|
||||
val items = emptyList<ChecklistItemState>()
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("new", result[0].id)
|
||||
assertEquals(0, result[0].order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - order values are sequential after add item`() {
|
||||
val items = listOf(
|
||||
item("a", checked = false, order = 0),
|
||||
item("b", checked = false, order = 1),
|
||||
item("c", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
result.forEachIndexed { index, item ->
|
||||
assertEquals("Order at index $index should be $index", index, item.order)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IMPL_15 - existing items do not change position after add item`() {
|
||||
// Kernforderung: Kein Item darf sich verschieben
|
||||
val items = listOf(
|
||||
item("cashews", checked = false, order = 0),
|
||||
item("noodles", checked = false, order = 1),
|
||||
item("coffee", checked = true, order = 2)
|
||||
)
|
||||
|
||||
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
|
||||
|
||||
// Relative Reihenfolge der bestehenden Items prüfen
|
||||
val existingIds = result.filter { it.id != "new" }.map { it.id }
|
||||
assertEquals(listOf("cashews", "noodles", "coffee"), existingIds)
|
||||
|
||||
// Cashews und Noodles müssen VOR dem neuen Item sein
|
||||
val cashewsIdx = result.indexOfFirst { it.id == "cashews" }
|
||||
val noodlesIdx = result.indexOfFirst { it.id == "noodles" }
|
||||
val newIdx = result.indexOfFirst { it.id == "new" }
|
||||
val coffeeIdx = result.indexOfFirst { it.id == "coffee" }
|
||||
|
||||
assertTrue("Cashews before new", cashewsIdx < newIdx)
|
||||
assertTrue("Noodles before new", noodlesIdx < newIdx)
|
||||
assertTrue("New before Coffee", newIdx < coffeeIdx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ⚡ v1.3.1: detekt Configuration
|
||||
# ⚡ v1.8.1: detekt Configuration
|
||||
# Pragmatic rules for simple-notes-sync
|
||||
|
||||
build:
|
||||
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
|
||||
maxIssues: 0 # v1.8.1: All issues resolved
|
||||
excludeCorrectable: false
|
||||
|
||||
config:
|
||||
|
||||
@@ -16,6 +16,9 @@ composeBom = "2026.01.00"
|
||||
navigationCompose = "2.7.6"
|
||||
lifecycleRuntimeCompose = "2.7.0"
|
||||
activityCompose = "1.8.2"
|
||||
room = "2.6.1"
|
||||
ksp = "2.0.0-1.0.21"
|
||||
koin = "3.5.3"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
||||
# Room Database
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
# Core Koin for Kotlin projects
|
||||
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||
# Koin for Jetpack Compose integration
|
||||
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
@@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
|
||||
@@ -276,8 +276,9 @@ Schritt-für-Schritt:
|
||||
|
||||
### Daten-Schutz
|
||||
- ✅ **Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion
|
||||
- ✅ **Keine Verschlüsselung** - Klartextformat für Lesbarkeit
|
||||
- ⚠️ **Sensible Daten?** - Backup-Datei selbst verschlüsseln (z.B. 7-Zip mit Passwort)
|
||||
- ✅ **Optionale Verschlüsselung** _(v1.7.0+)_ - Backup-Dateien mit Passwort schützen
|
||||
- ✅ **Menschenlesbar** - Klartextformat (JSON) wenn unverschlüsselt
|
||||
- ⚠️ **Sensible Daten?** - Verschlüsselung aktivieren oder externe Tools nutzen (z.B. 7-Zip)
|
||||
|
||||
### Empfehlungen
|
||||
- 🔐 Backup-Dateien in verschlüsseltem Container speichern
|
||||
@@ -321,4 +322,4 @@ Schritt-für-Schritt:
|
||||
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
|
||||
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown
|
||||
|
||||
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
|
||||
**Letzte Aktualisierung:** v1.8.1 (2026-02-11)
|
||||
|
||||
@@ -276,8 +276,9 @@ Step-by-step:
|
||||
|
||||
### Data Protection
|
||||
- ✅ **Locally stored** - No cloud upload without your action
|
||||
- ✅ **No encryption** - Plain text format for readability
|
||||
- ⚠️ **Sensitive data?** - Encrypt backup file yourself (e.g., 7-Zip with password)
|
||||
- ✅ **Optional encryption** _(v1.7.0+)_ - Password-protect backup files
|
||||
- ✅ **Human-readable** - Plain JSON format when unencrypted
|
||||
- ⚠️ **Sensitive data?** - Enable encryption or use external tools (e.g., 7-Zip)
|
||||
|
||||
### Recommendations
|
||||
- 🔐 Store backup files in encrypted container
|
||||
@@ -317,8 +318,8 @@ Step-by-step:
|
||||
---
|
||||
|
||||
**📚 See also:**
|
||||
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup
|
||||
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
|
||||
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown
|
||||
- [QUICKSTART.md](../QUICKSTART.md) - App installation and setup
|
||||
- [FEATURES.md](FEATURES.md) - Complete feature list
|
||||
- [DESKTOP.md](DESKTOP.md) - Desktop integration with Markdown
|
||||
|
||||
**Last update:** v1.2.1 (2026-01-05)
|
||||
**Last update:** v1.8.1 (2026-02-11)
|
||||
|
||||
@@ -48,8 +48,6 @@ git push origin fix/my-bug
|
||||
|
||||
## 📱 Installation auf Gerät
|
||||
|
||||
## 📱 Installation auf Gerät
|
||||
|
||||
### Mit ADB (Empfohlen - sauberes Testing)
|
||||
```bash
|
||||
# Gerät verbinden
|
||||
|
||||
@@ -541,17 +541,7 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
### v1.1
|
||||
- [ ] Suche & Filter
|
||||
- [ ] Dark Mode
|
||||
- [ ] Tags/Kategorien
|
||||
- [ ] Markdown Preview
|
||||
|
||||
### v2.0
|
||||
- [ ] Desktop Client (Flutter)
|
||||
- [ ] End-to-End Verschlüsselung
|
||||
- [ ] Shared Notes (Collaboration)
|
||||
- [ ] Attachment Support
|
||||
Siehe [UPCOMING.md](UPCOMING.md) für die vollständige Roadmap und geplante Features.
|
||||
|
||||
---
|
||||
|
||||
@@ -564,4 +554,4 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 25. Dezember 2025
|
||||
**Letzte Aktualisierung:** Februar 2026
|
||||
|
||||
14
docs/DOCS.md
@@ -541,17 +541,7 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
### v1.1
|
||||
- [ ] Search & Filter
|
||||
- [ ] Dark Mode
|
||||
- [ ] Tags/Categories
|
||||
- [ ] Markdown Preview
|
||||
|
||||
### v2.0
|
||||
- [ ] Desktop Client (Flutter)
|
||||
- [ ] End-to-End Encryption
|
||||
- [ ] Shared Notes (Collaboration)
|
||||
- [ ] Attachment Support
|
||||
See [UPCOMING.md](UPCOMING.md) for the full roadmap and planned features.
|
||||
|
||||
---
|
||||
|
||||
@@ -564,4 +554,4 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** December 25, 2025
|
||||
**Last updated:** February 2026
|
||||
|
||||
@@ -37,6 +37,50 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 Ansichten & Layout _(NEU in v1.7.0+)_
|
||||
|
||||
### Darstellungsmodi
|
||||
- ✅ **Listenansicht** - Klassisches Listen-Layout
|
||||
- ✅ **Rasteransicht** _(NEU in v1.7.0)_ - Pinterest-artiges Staggered Grid mit dynamischen Vorschauzeilen
|
||||
- ✅ **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht wechseln
|
||||
- ✅ **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
|
||||
- ✅ **Grid als Standard** _(v1.8.0)_ - Neue Installationen starten im Grid-Modus
|
||||
|
||||
### Notiz-Sortierung _(NEU in v1.8.0)_
|
||||
- ✅ **Nach Änderungsdatum** - Neueste oder älteste zuerst
|
||||
- ✅ **Nach Erstelldatum** - Nach Erstellungszeitpunkt
|
||||
- ✅ **Nach Titel** - A-Z oder Z-A
|
||||
- ✅ **Nach Typ** - Textnotizen vs. Checklisten
|
||||
- ✅ **Persistente Einstellungen** - Sortier-Option bleibt nach App-Neustart
|
||||
- ✅ **Sortier-Dialog** - Richtungswahl im Hauptbildschirm
|
||||
|
||||
### Checklisten-Sortierung _(NEU in v1.8.0)_
|
||||
- ✅ **Manuell** - Eigene Drag & Drop Reihenfolge
|
||||
- ✅ **Alphabetisch** - A-Z Sortierung
|
||||
- ✅ **Offene zuerst** - Unerledigte Items oben
|
||||
- ✅ **Erledigte zuletzt** - Abgehakte Items unten
|
||||
- ✅ **Visueller Trenner** - Zwischen offenen/erledigten Gruppen mit Anzahl
|
||||
- ✅ **Auto-Sortierung** - Neu sortieren beim Abhaken/Öffnen
|
||||
- ✅ **Drag über Grenzen** - Items wechseln Status beim Überqueren des Trenners
|
||||
|
||||
---
|
||||
|
||||
## 📌 Homescreen-Widgets _(NEU in v1.8.0)_
|
||||
|
||||
### Widget-Features
|
||||
- ✅ **Textnotiz-Widget** - Beliebige Notiz auf dem Homescreen anzeigen
|
||||
- ✅ **Checklisten-Widget** - Interaktive Checkboxen mit Sync zum Server
|
||||
- ✅ **5 Größenklassen** - SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL
|
||||
- ✅ **Material You Farben** - Dynamische Farben passend zum System-Theme
|
||||
- ✅ **Einstellbare Transparenz** - Hintergrund-Opazität (0-100%)
|
||||
- ✅ **Sperr-Umschalter** - Versehentliche Bearbeitungen verhindern
|
||||
- ✅ **Auto-Aktualisierung** - Updates nach Sync-Abschluss
|
||||
- ✅ **Konfigurations-Activity** - Notiz-Auswahl und Einstellungen
|
||||
- ✅ **Checklisten-Sortierung** _(v1.8.1)_ - Widgets übernehmen Sortier-Option
|
||||
- ✅ **Visuelle Trenner** _(v1.8.1)_ - Zwischen offenen/erledigten Items
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Mehrsprachigkeit _(NEU in v1.5.0)_
|
||||
|
||||
### Unterstützte Sprachen
|
||||
@@ -129,9 +173,12 @@
|
||||
### Sync-Mechanismus
|
||||
- ✅ **Upload** - Lokale Änderungen zum Server
|
||||
- ✅ **Download** - Server-Änderungen in App
|
||||
- ✅ **Parallele Downloads** _(NEU in v1.8.0)_ - Bis zu 5 gleichzeitige Downloads
|
||||
- ✅ **Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
|
||||
- ✅ **Konfliktfreies Merging** - Last-Write-Wins via Timestamp
|
||||
- ✅ **Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
|
||||
- ✅ **Server-Löschungs-Erkennung** _(NEU in v1.8.0)_ - Erkennt auf anderen Geräten gelöschte Notizen
|
||||
- ✅ **Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT, DELETED_ON_SERVER
|
||||
- ✅ **Live Fortschritts-UI** _(NEU in v1.8.0)_ - Phasen-Anzeige mit Upload/Download-Zählern
|
||||
- ✅ **Fehlerbehandlung** - Retry bei Netzwerkproblemen
|
||||
- ✅ **Offline-First** - App funktioniert ohne Server
|
||||
|
||||
@@ -140,6 +187,9 @@
|
||||
- ✅ **HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
|
||||
- ✅ **Username/Password** - Basic Authentication
|
||||
- ✅ **Connection Test** - In Einstellungen testen
|
||||
- ✅ **WiFi-Only Sync** _(NEU in v1.7.0)_ - Option nur über WiFi zu synchronisieren
|
||||
- ✅ **VPN-Unterstützung** _(NEU in v1.7.0)_ - Sync funktioniert korrekt über VPN-Tunnels
|
||||
- ✅ **Self-Signed SSL** _(NEU in v1.7.0)_ - Unterstützung für selbstsignierte Zertifikate
|
||||
- ✅ **Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
|
||||
- ✅ **Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
|
||||
|
||||
@@ -196,11 +246,12 @@
|
||||
## 🛠️ Technische Details
|
||||
|
||||
### Plattform
|
||||
- ✅ **Android 8.0+** (API 26+)
|
||||
- ✅ **Android 7.0+** (API 24+)
|
||||
- ✅ **Target SDK 36** (Android 15)
|
||||
- ✅ **Kotlin** - Moderne Programmiersprache
|
||||
- ✅ **Jetpack Compose** - Deklaratives UI-Framework
|
||||
- ✅ **Material Design 3** - Neueste Design-Richtlinien
|
||||
- ✅ **ViewBinding** - Typ-sichere View-Referenzen
|
||||
- ✅ **Jetpack Glance** _(v1.8.0)_ - Widget-Framework
|
||||
|
||||
### Architektur
|
||||
- ✅ **MVVM-Light** - Einfache Architektur
|
||||
@@ -218,6 +269,7 @@
|
||||
- ✅ **Gson** - JSON Serialization
|
||||
- ✅ **WorkManager** - Background Tasks
|
||||
- ✅ **OkHttp** - HTTP Client (via Sardine)
|
||||
- ✅ **Glance** _(v1.8.0)_ - Widget-Framework
|
||||
|
||||
### Build-Varianten
|
||||
- ✅ **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
|
||||
@@ -247,22 +299,12 @@
|
||||
|
||||
## 🔮 Zukünftige Features
|
||||
|
||||
Geplant für kommende Versionen:
|
||||
Geplant für kommende Versionen – siehe [UPCOMING.md](UPCOMING.md) für die vollständige Roadmap.
|
||||
|
||||
### v1.4.0 - Checklisten
|
||||
- ⏳ **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
|
||||
- ⏳ **Erledigte Items** - Durchstreichen/Abhaken
|
||||
- ⏳ **Drag & Drop** - Items neu anordnen
|
||||
|
||||
### v1.5.0 - Internationalisierung
|
||||
- ⏳ **Mehrsprachigkeit** - Deutsch + Englisch UI
|
||||
- ⏳ **Sprachauswahl** - In Einstellungen wählbar
|
||||
- ⏳ **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
|
||||
|
||||
### v1.6.0 - Modern APIs
|
||||
- ⏳ **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
|
||||
- ⏳ **PackageInfo Flags** - PackageInfoFlags.of() verwenden
|
||||
- ⏳ **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
|
||||
### v2.0.0 - Legacy Cleanup
|
||||
- ⏳ **Veraltete Activities entfernen** - Durch Compose-Varianten ersetzen
|
||||
- ⏳ **LocalBroadcastManager → SharedFlow** - Moderne Event-Architektur
|
||||
- ⏳ **WebDavSyncService aufteilen** - SyncOrchestrator, NoteUploader, NoteDownloader
|
||||
|
||||
---
|
||||
|
||||
@@ -305,4 +347,4 @@ A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)
|
||||
**Letzte Aktualisierung:** v1.8.1 (2026-02-11)
|
||||
|
||||
@@ -37,6 +37,50 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 Views & Layout _(NEW in v1.7.0+)_
|
||||
|
||||
### Display Modes
|
||||
- ✅ **List View** - Classic list layout
|
||||
- ✅ **Grid View** _(NEW in v1.7.0)_ - Pinterest-style staggered grid with dynamic preview lines
|
||||
- ✅ **Layout toggle** - Switch between list and grid in settings
|
||||
- ✅ **Adaptive columns** - 2-3 columns based on screen size
|
||||
- ✅ **Grid as default** _(v1.8.0)_ - New installations default to grid view
|
||||
|
||||
### Note Sorting _(NEW in v1.8.0)_
|
||||
- ✅ **Sort by Updated** - Newest or oldest first
|
||||
- ✅ **Sort by Created** - By creation date
|
||||
- ✅ **Sort by Title** - A-Z or Z-A
|
||||
- ✅ **Sort by Type** - Text notes vs checklists
|
||||
- ✅ **Persistent preferences** - Sort option saved across app restarts
|
||||
- ✅ **Sort dialog** - Direction toggle in main screen
|
||||
|
||||
### Checklist Sorting _(NEW in v1.8.0)_
|
||||
- ✅ **Manual** - Custom drag & drop order
|
||||
- ✅ **Alphabetical** - A-Z sorting
|
||||
- ✅ **Unchecked First** - Unchecked items on top
|
||||
- ✅ **Checked Last** - Checked items at bottom
|
||||
- ✅ **Visual separator** - Between unchecked/checked groups with count
|
||||
- ✅ **Auto-sort on toggle** - Re-sorts when checking/unchecking items
|
||||
- ✅ **Drag across boundaries** - Items auto-toggle state when crossing separator
|
||||
|
||||
---
|
||||
|
||||
## 📌 Homescreen Widgets _(NEW in v1.8.0)_
|
||||
|
||||
### Widget Features
|
||||
- ✅ **Text note widget** - Display any note on homescreen
|
||||
- ✅ **Checklist widget** - Interactive checkboxes that sync to server
|
||||
- ✅ **5 size classes** - SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL
|
||||
- ✅ **Material You colors** - Dynamic colors matching system theme
|
||||
- ✅ **Configurable opacity** - Background transparency (0-100%)
|
||||
- ✅ **Lock toggle** - Prevent accidental edits
|
||||
- ✅ **Auto-refresh** - Updates after sync completion
|
||||
- ✅ **Configuration activity** - Note selection and settings
|
||||
- ✅ **Checklist sorting** _(v1.8.1)_ - Widgets respect saved sort option
|
||||
- ✅ **Visual separators** _(v1.8.1)_ - Between unchecked/checked items
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Multilingual Support _(NEW in v1.5.0)_
|
||||
|
||||
### Supported Languages
|
||||
@@ -129,9 +173,12 @@
|
||||
### Sync Mechanism
|
||||
- ✅ **Upload** - Local changes to server
|
||||
- ✅ **Download** - Server changes to app
|
||||
- ✅ **Parallel downloads** _(NEW in v1.8.0)_ - Up to 5 simultaneous downloads
|
||||
- ✅ **Conflict detection** - On simultaneous changes
|
||||
- ✅ **Conflict-free merging** - Last-Write-Wins via timestamp
|
||||
- ✅ **Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
|
||||
- ✅ **Server deletion detection** _(NEW in v1.8.0)_ - Detects notes deleted on other devices
|
||||
- ✅ **Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT, DELETED_ON_SERVER
|
||||
- ✅ **Live progress UI** _(NEW in v1.8.0)_ - Phase indicators with upload/download counters
|
||||
- ✅ **Error handling** - Retry on network issues
|
||||
- ✅ **Offline-first** - App works without server
|
||||
|
||||
@@ -140,6 +187,9 @@
|
||||
- ✅ **HTTP/HTTPS** - HTTP only local, HTTPS for external
|
||||
- ✅ **Username/password** - Basic authentication
|
||||
- ✅ **Connection test** - Test in settings
|
||||
- ✅ **WiFi-only sync** _(NEW in v1.7.0)_ - Option to sync only on WiFi
|
||||
- ✅ **VPN support** _(NEW in v1.7.0)_ - Sync works correctly through VPN tunnels
|
||||
- ✅ **Self-signed SSL** _(NEW in v1.7.0)_ - Support for self-signed certificates
|
||||
- ✅ **Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
|
||||
- ✅ **Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
|
||||
|
||||
@@ -196,11 +246,12 @@
|
||||
## 🛠️ Technical Details
|
||||
|
||||
### Platform
|
||||
- ✅ **Android 8.0+** (API 26+)
|
||||
- ✅ **Android 7.0+** (API 24+)
|
||||
- ✅ **Target SDK 36** (Android 15)
|
||||
- ✅ **Kotlin** - Modern programming language
|
||||
- ✅ **Jetpack Compose** - Declarative UI framework
|
||||
- ✅ **Material Design 3** - Latest design guidelines
|
||||
- ✅ **ViewBinding** - Type-safe view references
|
||||
- ✅ **Jetpack Glance** _(v1.8.0)_ - Widget framework
|
||||
|
||||
### Architecture
|
||||
- ✅ **MVVM-Light** - Simple architecture
|
||||
@@ -218,6 +269,7 @@
|
||||
- ✅ **Gson** - JSON serialization
|
||||
- ✅ **WorkManager** - Background tasks
|
||||
- ✅ **OkHttp** - HTTP client (via Sardine)
|
||||
- ✅ **Glance** _(v1.8.0)_ - Widget framework
|
||||
|
||||
### Build Variants
|
||||
- ✅ **Standard** - Universal APK (100% FOSS, no Google dependencies)
|
||||
@@ -247,22 +299,12 @@
|
||||
|
||||
## 🔮 Future Features
|
||||
|
||||
Planned for upcoming versions:
|
||||
Planned for upcoming versions – see [UPCOMING.md](UPCOMING.md) for the full roadmap.
|
||||
|
||||
### v1.4.0 - Checklists
|
||||
- ⏳ **Checklist notes** - New note type with checkboxes
|
||||
- ⏳ **Completed items** - Strike-through/check off
|
||||
- ⏳ **Drag & drop** - Reorder items
|
||||
|
||||
### v1.5.0 - Internationalization
|
||||
- ⏳ **Multi-language** - German + English UI
|
||||
- ⏳ **Language selection** - Selectable in settings
|
||||
- ⏳ **Full translation** - All strings in both languages
|
||||
|
||||
### v1.6.0 - Modern APIs
|
||||
- ⏳ **Replace LocalBroadcastManager** - Use SharedFlow instead
|
||||
- ⏳ **PackageInfo Flags** - Use PackageInfoFlags.of()
|
||||
- ⏳ **Complexity refactoring** - Split long functions
|
||||
### v2.0.0 - Legacy Cleanup
|
||||
- ⏳ **Remove deprecated Activities** - Replace with Compose equivalents
|
||||
- ⏳ **LocalBroadcastManager → SharedFlow** - Modern event architecture
|
||||
- ⏳ **WebDavSyncService split** - SyncOrchestrator, NoteUploader, NoteDownloader
|
||||
|
||||
---
|
||||
|
||||
@@ -305,4 +347,4 @@ A: Yes! Download the APK directly from GitHub or use F-Droid.
|
||||
|
||||
---
|
||||
|
||||
**Last update:** v1.3.2 (2026-01-10)
|
||||
**Last update:** v1.8.1 (2026-02-11)
|
||||
|
||||
146
docs/SELF_SIGNED_SSL.de.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Selbstsignierte SSL-Zertifikate
|
||||
|
||||
**Seit:** v1.7.0
|
||||
**Status:** ✅ Unterstützt
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](SELF_SIGNED_SSL.md)
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
Simple Notes Sync unterstützt die Verbindung zu WebDAV-Servern mit selbstsignierten SSL-Zertifikaten, z.B.:
|
||||
- ownCloud/Nextcloud mit selbstsignierten Zertifikaten
|
||||
- Synology NAS mit Standard-Zertifikaten
|
||||
- Raspberry Pi oder Home-Server
|
||||
- Interne Firmen-Server mit privaten CAs
|
||||
|
||||
## Anleitung
|
||||
|
||||
### Schritt 1: CA-Zertifikat des Servers exportieren
|
||||
|
||||
**Auf deinem Server:**
|
||||
|
||||
1. Finde deine Zertifikatsdatei (meist `.crt`, `.pem` oder `.der` Format)
|
||||
2. Falls du das Zertifikat selbst erstellt hast, hast du es bereits
|
||||
3. Für Synology NAS: Systemsteuerung → Sicherheit → Zertifikat → Exportieren
|
||||
4. Für ownCloud/Nextcloud: Meist unter `/etc/ssl/certs/` auf dem Server
|
||||
|
||||
### Schritt 2: Zertifikat auf Android installieren
|
||||
|
||||
**Auf deinem Android-Gerät:**
|
||||
|
||||
1. **Übertrage** die `.crt` oder `.pem` Datei auf dein Handy (per E-Mail, USB, etc.)
|
||||
|
||||
2. **Öffne Einstellungen** → Sicherheit → Weitere Sicherheitseinstellungen (oder Verschlüsselung & Anmeldedaten)
|
||||
|
||||
3. **Von Speicher installieren** / "Zertifikat installieren"
|
||||
- Wähle "CA-Zertifikat"
|
||||
- **Warnung:** Android zeigt eine Sicherheitswarnung. Das ist normal.
|
||||
- Tippe auf "Trotzdem installieren"
|
||||
|
||||
4. **Navigiere** zu deiner Zertifikatsdatei und wähle sie aus
|
||||
|
||||
5. **Benenne** es erkennbar (z.B. "Mein ownCloud CA")
|
||||
|
||||
6. ✅ **Fertig!** Das Zertifikat wird nun systemweit vertraut
|
||||
|
||||
### Schritt 3: Simple Notes Sync verbinden
|
||||
|
||||
1. Öffne Simple Notes Sync
|
||||
2. Gehe zu **Einstellungen** → **Server-Einstellungen**
|
||||
3. Gib deine **`https://` Server-URL** wie gewohnt ein
|
||||
4. Die App vertraut nun deinem selbstsignierten Zertifikat ✅
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
### ⚠️ Wichtig
|
||||
|
||||
- Die Installation eines CA-Zertifikats gewährt Vertrauen für **alle** von dieser CA signierten Zertifikate
|
||||
- Installiere nur Zertifikate aus vertrauenswürdigen Quellen
|
||||
- Android warnt dich vor der Installation – lies die Warnung sorgfältig
|
||||
|
||||
### 🔒 Warum das sicher ist
|
||||
|
||||
- Du installierst das Zertifikat **manuell** (bewusste Entscheidung)
|
||||
- Die App nutzt Androids nativen Trust Store (keine eigene Validierung)
|
||||
- Du kannst das Zertifikat jederzeit in den Android-Einstellungen entfernen
|
||||
- F-Droid und Google Play konform (kein "allen vertrauen" Hack)
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Zertifikat nicht vertraut
|
||||
|
||||
**Problem:** App zeigt weiterhin SSL-Fehler nach Zertifikatsinstallation
|
||||
|
||||
**Lösungen:**
|
||||
1. **Installation prüfen:** Einstellungen → Sicherheit → Vertrauenswürdige Anmeldedaten → Tab "Nutzer"
|
||||
2. **Zertifikatstyp prüfen:** Muss ein CA-Zertifikat sein, kein Server-Zertifikat
|
||||
3. **App neustarten:** Simple Notes Sync schließen und wieder öffnen
|
||||
4. **URL prüfen:** Muss `https://` verwenden (nicht `http://`)
|
||||
|
||||
### Selbstsigniert vs. CA-signiert
|
||||
|
||||
| Typ | Installation nötig | Sicherheit |
|
||||
|-----|-------------------|------------|
|
||||
| **Selbstsigniert** | ✅ Ja | Manuelles Vertrauen |
|
||||
| **Let's Encrypt** | ❌ Nein | Automatisch |
|
||||
| **Private CA** | ✅ Ja (CA-Root) | Automatisch für alle CA-signierten Zertifikate |
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Let's Encrypt (Empfohlen)
|
||||
|
||||
Wenn dein Server öffentlich erreichbar ist, erwäge **Let's Encrypt** für kostenlose, automatisch erneuerte SSL-Zertifikate:
|
||||
|
||||
- Keine manuelle Zertifikatsinstallation nötig
|
||||
- Von allen Geräten automatisch vertraut
|
||||
- Einfacher für Endbenutzer
|
||||
|
||||
---
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Implementierung
|
||||
|
||||
- Nutzt Androids **Network Security Config**
|
||||
- Vertraut sowohl System- als auch Benutzer-CA-Zertifikaten
|
||||
- Kein eigener TrustManager oder HostnameVerifier
|
||||
- F-Droid und Play Store konform
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Datei: `android/app/src/main/res/xml/network_security_config.xml`
|
||||
|
||||
```xml
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" /> <!-- ← Aktiviert Self-Signed Support -->
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**F: Muss ich das Zertifikat nach App-Updates neu installieren?**
|
||||
A: Nein, Zertifikate werden systemweit gespeichert, nicht pro App.
|
||||
|
||||
**F: Kann ich dasselbe Zertifikat für mehrere Apps verwenden?**
|
||||
A: Ja, einmal installiert funktioniert es für alle Apps die Benutzerzertifikaten vertrauen.
|
||||
|
||||
**F: Wie entferne ich ein Zertifikat?**
|
||||
A: Einstellungen → Sicherheit → Vertrauenswürdige Anmeldedaten → Tab "Nutzer" → Zertifikat antippen → Entfernen
|
||||
|
||||
**F: Funktioniert das auf Android 14+?**
|
||||
A: Ja, getestet auf Android 7 bis 15 (API 24-35).
|
||||
|
||||
---
|
||||
|
||||
**Hilfe nötig?** Erstelle ein Issue auf [GitHub](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
@@ -60,28 +60,91 @@
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 - Staggered Grid Layout
|
||||
## v1.7.0 - Grid View, WiFi-Only & VPN ✅
|
||||
|
||||
> **Status:** Geplant 📝
|
||||
> **Status:** Released 🎉 (Januar 2026)
|
||||
|
||||
### 🎨 Adaptives Layout
|
||||
### 🎨 Grid Layout
|
||||
|
||||
- **Staggered Grid** - Pinterest-artiges Layout mit `LazyVerticalStaggeredGrid`
|
||||
- **Intelligente Größen** - Kleine Notizen (kurzer Text, wenige Checklist-Items) kompakt dargestellt
|
||||
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht in Einstellungen wechseln
|
||||
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
|
||||
- **120 FPS optimiert** - Lazy Loading für flüssiges Scrollen bei vielen Notizen
|
||||
- ✅ **Pinterest-artiges Staggered Grid** - Lückenfreies Layout mit dynamischen Vorschauzeilen
|
||||
- ✅ **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht wechseln
|
||||
- ✅ **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
|
||||
|
||||
### 🔧 Server-Ordner Prüfung
|
||||
### 📡 Sync-Verbesserungen
|
||||
|
||||
- **WebDAV Folder Check** - Prüft ob der Ordner auf dem Server existiert und beschreibbar ist
|
||||
- **Bessere Fehlermeldungen** - Hilfreiche Hinweise bei Server-Problemen
|
||||
- **Connection-Test Verbesserung** - Prüft Read/Write Permissions
|
||||
- ✅ **WiFi-Only Sync Toggle** - Nur über WiFi synchronisieren
|
||||
- ✅ **VPN-Unterstützung** - Sync funktioniert korrekt über VPN-Tunnels
|
||||
- ✅ **Self-Signed SSL** - Dokumentation und Unterstützung für selbstsignierte Zertifikate
|
||||
- ✅ **Server-Wechsel-Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei URL-Änderung
|
||||
|
||||
### 🔧 Technische Verbesserungen
|
||||
---
|
||||
|
||||
- **Code-Refactoring** - LargeClass Komponenten aufteilen (WebDavSyncService, SettingsActivity)
|
||||
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
|
||||
## v1.7.1 - Android 9 Fix & VPN ✅
|
||||
|
||||
> **Status:** Released 🎉 (Februar 2026)
|
||||
|
||||
- ✅ **Android 9 Crash Fix** - `getForegroundInfo()` für WorkManager auf API 28 implementiert
|
||||
- ✅ **VPN-Kompatibilität** - WiFi Socket-Binding erkennt Wireguard VPN-Interfaces
|
||||
- ✅ **SafeSardineWrapper** - Saubere HTTP-Verbindungs-Bereinigung
|
||||
|
||||
---
|
||||
|
||||
## v1.7.2 - Timestamp & Löschungs-Fixes ✅
|
||||
|
||||
> **Status:** Released 🎉 (Februar 2026)
|
||||
|
||||
- ✅ **Server-mtime als Wahrheitsquelle** - Behebt Timestamp-Probleme mit externen Editoren
|
||||
- ✅ **Deletion Tracker Mutex** - Thread-sichere Batch-Löschungen
|
||||
- ✅ **ISO8601 Timezone-Parsing** - Multi-Format-Unterstützung
|
||||
- ✅ **E-Tag Batch-Caching** - Performance-Verbesserung
|
||||
- ✅ **Memory Leak Prävention** - SafeSardineWrapper mit Closeable
|
||||
|
||||
---
|
||||
|
||||
## v1.8.0 - Widgets, Sortierung & Erweiterter Sync ✅
|
||||
|
||||
> **Status:** Released 🎉 (Februar 2026)
|
||||
|
||||
### 📌 Homescreen-Widgets
|
||||
|
||||
- ✅ **Volles Jetpack Glance Framework** - 5 responsive Größenklassen
|
||||
- ✅ **Interaktive Checklisten** - Checkboxen die zum Server synchronisieren
|
||||
- ✅ **Material You Farben** - Dynamische Farben mit einstellbarer Opazität
|
||||
- ✅ **Sperr-Umschalter** - Versehentliche Bearbeitungen verhindern
|
||||
- ✅ **Konfigurations-Activity** - Notiz-Auswahl und Einstellungen
|
||||
|
||||
### 📊 Sortierung
|
||||
|
||||
- ✅ **Notiz-Sortierung** - Nach Titel, Änderungsdatum, Erstelldatum, Typ
|
||||
- ✅ **Checklisten-Sortierung** - Manuell, alphabetisch, offene zuerst, erledigte zuletzt
|
||||
- ✅ **Visuelle Trenner** - Zwischen offenen/erledigten Gruppen
|
||||
- ✅ **Drag über Grenzen** - Auto-Toggle beim Überqueren des Trenners
|
||||
|
||||
### 🔄 Sync-Verbesserungen
|
||||
|
||||
- ✅ **Parallele Downloads** - Bis zu 5 gleichzeitig (konfigurierbar)
|
||||
- ✅ **Server-Löschungs-Erkennung** - Erkennt auf anderen Clients gelöschte Notizen
|
||||
- ✅ **Live Sync-Fortschritt** - Phasen-Anzeige mit Zählern
|
||||
- ✅ **Sync-Status Legende** - Hilfe-Dialog für alle Sync-Icons
|
||||
|
||||
### ✨ UX
|
||||
|
||||
- ✅ **Post-Update Changelog** - Zeigt lokalisierten Changelog nach Update
|
||||
- ✅ **Grid als Standard** - Neue Installationen starten im Grid-Modus
|
||||
- ✅ **Toast → Banner Migration** - Einheitliches Benachrichtigungssystem
|
||||
|
||||
---
|
||||
|
||||
## v1.8.1 - Bugfix & Polish ✅
|
||||
|
||||
> **Status:** Released 🎉 (Februar 2026)
|
||||
|
||||
- ✅ **Checklisten-Sortierung Persistenz** - Sortier-Option korrekt wiederhergestellt
|
||||
- ✅ **Widget Scroll Fix** - Scroll funktioniert auf Standard 3×2 Widget-Größe
|
||||
- ✅ **Widget Checklisten-Sortierung** - Widgets übernehmen gespeicherte Sortier-Option
|
||||
- ✅ **Drag Cross-Boundary** - Drag & Drop über Checked/Unchecked-Trenner
|
||||
- ✅ **Sync Rate-Limiting** - Globaler 30s Cooldown zwischen Auto-Syncs
|
||||
- ✅ **Detekt: 0 Issues** - Alle 12 Findings behoben
|
||||
|
||||
---
|
||||
|
||||
@@ -110,7 +173,6 @@
|
||||
|
||||
### 🎨 UI Features
|
||||
|
||||
- **Widget** - Schnellzugriff vom Homescreen
|
||||
- **Kategorien/Tags** - Notizen organisieren
|
||||
- **Suche** - Volltextsuche in Notizen
|
||||
|
||||
|
||||
@@ -60,28 +60,91 @@
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 - Staggered Grid Layout
|
||||
## v1.7.0 - Grid View, WiFi-Only & VPN ✅
|
||||
|
||||
> **Status:** Planned 📝
|
||||
> **Status:** Released 🎉 (January 2026)
|
||||
|
||||
### 🎨 Adaptive Layout
|
||||
### 🎨 Grid Layout
|
||||
|
||||
- **Staggered Grid** - Pinterest-style layout using `LazyVerticalStaggeredGrid`
|
||||
- **Smart sizing** - Small notes (short text, few checklist items) displayed compactly
|
||||
- **Layout toggle** - Switch between List and Grid view in settings
|
||||
- **Adaptive columns** - 2-3 columns based on screen size
|
||||
- **120 FPS optimized** - Lazy loading for smooth scrolling with many notes
|
||||
- ✅ **Pinterest-style staggered grid** - Gapless layout with dynamic preview lines
|
||||
- ✅ **Layout toggle** - Switch between list and grid in settings
|
||||
- ✅ **Adaptive columns** - 2-3 columns based on screen size
|
||||
|
||||
### 🔧 Server Folder Check
|
||||
### 📡 Sync Improvements
|
||||
|
||||
- **WebDAV folder check** - Checks if folder exists and is writable on server
|
||||
- **Better error messages** - Helpful hints for server problems
|
||||
- **Connection test improvement** - Checks read/write permissions
|
||||
- ✅ **WiFi-only sync toggle** - Sync only when connected to WiFi
|
||||
- ✅ **VPN support** - Sync works correctly through VPN tunnels
|
||||
- ✅ **Self-signed SSL** - Documentation and support for self-signed certificates
|
||||
- ✅ **Server change detection** - All notes reset to PENDING when server URL changes
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
---
|
||||
|
||||
- **Code refactoring** - Split LargeClass components (WebDavSyncService, SettingsActivity)
|
||||
- **Improved progress dialogs** - Material Design 3 compliant
|
||||
## v1.7.1 - Android 9 Fix & VPN ✅
|
||||
|
||||
> **Status:** Released 🎉 (February 2026)
|
||||
|
||||
- ✅ **Android 9 crash fix** - Implemented `getForegroundInfo()` for WorkManager on API 28
|
||||
- ✅ **VPN compatibility** - WiFi socket binding detects Wireguard VPN interfaces
|
||||
- ✅ **SafeSardineWrapper** - Proper HTTP connection cleanup
|
||||
|
||||
---
|
||||
|
||||
## v1.7.2 - Timestamp & Deletion Fixes ✅
|
||||
|
||||
> **Status:** Released 🎉 (February 2026)
|
||||
|
||||
- ✅ **Server mtime as source of truth** - Fixes external editor timestamp issues
|
||||
- ✅ **Deletion tracker mutex** - Thread-safe batch deletes
|
||||
- ✅ **ISO8601 timezone parsing** - Multi-format support
|
||||
- ✅ **E-Tag batch caching** - Performance improvement
|
||||
- ✅ **Memory leak prevention** - SafeSardineWrapper with Closeable
|
||||
|
||||
---
|
||||
|
||||
## v1.8.0 - Widgets, Sorting & Advanced Sync ✅
|
||||
|
||||
> **Status:** Released 🎉 (February 2026)
|
||||
|
||||
### 📌 Homescreen Widgets
|
||||
|
||||
- ✅ **Full Jetpack Glance framework** - 5 responsive size classes
|
||||
- ✅ **Interactive checklists** - Checkboxes that sync to server
|
||||
- ✅ **Material You colors** - Dynamic colors with configurable opacity
|
||||
- ✅ **Lock toggle** - Prevent accidental edits
|
||||
- ✅ **Configuration activity** - Note selection and settings
|
||||
|
||||
### 📊 Sorting
|
||||
|
||||
- ✅ **Note sorting** - By title, date modified, date created, type
|
||||
- ✅ **Checklist sorting** - Manual, alphabetical, unchecked first, checked last
|
||||
- ✅ **Visual separators** - Between unchecked/checked groups
|
||||
- ✅ **Drag across boundaries** - Auto-toggle state on cross-boundary drag
|
||||
|
||||
### 🔄 Sync Improvements
|
||||
|
||||
- ✅ **Parallel downloads** - Up to 5 simultaneous (configurable)
|
||||
- ✅ **Server deletion detection** - Detects notes deleted on other clients
|
||||
- ✅ **Live sync progress** - Phase indicators with counters
|
||||
- ✅ **Sync status legend** - Help dialog explaining all sync icons
|
||||
|
||||
### ✨ UX
|
||||
|
||||
- ✅ **Post-update changelog** - Shows localized changelog on first launch after update
|
||||
- ✅ **Grid as default** - New installations default to grid view
|
||||
- ✅ **Toast → Banner migration** - Unified notification system
|
||||
|
||||
---
|
||||
|
||||
## v1.8.1 - Bugfix & Polish ✅
|
||||
|
||||
> **Status:** Released 🎉 (February 2026)
|
||||
|
||||
- ✅ **Checklist sort persistence** - Sort option correctly restored when reopening
|
||||
- ✅ **Widget scroll fix** - Scroll works on standard 3×2 widget size
|
||||
- ✅ **Widget checklist sorting** - Widgets apply saved sort option
|
||||
- ✅ **Drag cross-boundary** - Drag & drop across checked/unchecked separator
|
||||
- ✅ **Sync rate-limiting** - Global 30s cooldown between auto-syncs
|
||||
- ✅ **Detekt: 0 issues** - All 12 findings resolved
|
||||
|
||||
---
|
||||
|
||||
@@ -110,7 +173,6 @@
|
||||
|
||||
### 🎨 UI Features
|
||||
|
||||
- **Widget** - Quick access from homescreen
|
||||
- **Categories/Tags** - Organize notes
|
||||
- **Search** - Full-text search in notes
|
||||
|
||||
|
||||
@@ -5,34 +5,50 @@ Diese Verzeichnisstruktur enthält alle Metadaten für die F-Droid-Veröffentlic
|
||||
## Struktur
|
||||
|
||||
```
|
||||
fastlane/metadata/android/de-DE/
|
||||
├── title.txt # App-Name (max 50 Zeichen)
|
||||
├── short_description.txt # Kurzbeschreibung (max 80 Zeichen)
|
||||
├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen)
|
||||
├── changelogs/
|
||||
│ └── 1.txt # Changelog für Version 1
|
||||
└── images/
|
||||
└── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit)
|
||||
├── 1.png # Hauptansicht (Notizliste)
|
||||
├── 2.png # Notiz-Editor
|
||||
├── 3.png # Settings
|
||||
└── 4.png # Empty State
|
||||
fastlane/metadata/android/
|
||||
├── de-DE/ # Deutsche Lokalisierung (primär)
|
||||
│ ├── title.txt # App-Name (max 50 Zeichen)
|
||||
│ ├── short_description.txt # Kurzbeschreibung (max 80 Zeichen)
|
||||
│ ├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen)
|
||||
│ ├── changelogs/
|
||||
│ │ ├── 1.txt ... 21.txt # Changelogs pro versionCode (max 500 Zeichen!)
|
||||
│ └── images/
|
||||
│ └── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit)
|
||||
│ ├── 1.png ... 5.png
|
||||
└── en-US/ # Englische Lokalisierung
|
||||
├── title.txt
|
||||
├── short_description.txt
|
||||
├── full_description.txt
|
||||
├── changelogs/
|
||||
│ ├── 1.txt ... 21.txt
|
||||
└── images/
|
||||
└── phoneScreenshots/
|
||||
```
|
||||
|
||||
## Wichtige Limits
|
||||
|
||||
| Feld | Max. Länge | Hinweis |
|
||||
|------|-----------|---------|
|
||||
| `title.txt` | 50 Zeichen | App-Name |
|
||||
| `short_description.txt` | 80 Zeichen | Kurzbeschreibung |
|
||||
| `full_description.txt` | 4000 Zeichen | Vollständige Beschreibung |
|
||||
| `changelogs/*.txt` | **500 Bytes** | Pro versionCode, **Bytes nicht Zeichen!** |
|
||||
|
||||
> **Achtung:** Changelogs werden in **Bytes** gemessen! UTF-8 Umlaute (ä, ö, ü) zählen als 2 Bytes.
|
||||
|
||||
## Screenshots erstellen
|
||||
|
||||
Verwende einen Android Emulator oder physisches Gerät mit:
|
||||
Verwende ein physisches Gerät oder Emulator mit:
|
||||
- Material You Theme aktiviert
|
||||
- Deutsche Sprache
|
||||
- Deutsche/Englische Sprache je nach Locale
|
||||
- Screenshots in hoher Auflösung (1080x2400 empfohlen)
|
||||
|
||||
### Screenshot-Reihenfolge:
|
||||
1. **Notizliste** - Mit mehreren Beispiel-Notizen, Sync-Status sichtbar
|
||||
2. **Editor** - Zeige eine bearbeitete Notiz mit Titel und Inhalt
|
||||
3. **Settings** - Server-Konfiguration mit erfolgreichem Server-Status
|
||||
4. **Empty State** - Schöne leere Ansicht mit Material 3 Card
|
||||
|
||||
## F-Droid Build-Konfiguration
|
||||
|
||||
Die App verwendet den `fdroid` Build-Flavor ohne proprietäre Dependencies.
|
||||
Siehe `build.gradle.kts` für Details.
|
||||
Siehe `android/app/build.gradle.kts` für Details.
|
||||
|
||||
## Aktuelle Version
|
||||
|
||||
- **versionName:** 1.8.1
|
||||
- **versionCode:** 21
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Unter der Haube haben wir ordentlich aufgeraumt:
|
||||
Unter der Haube haben wir ordentlich aufgeräumt:
|
||||
- Verbesserte Sync-Performance durch optimierten Code
|
||||
- Stabilere Fehlerbehandlung bei Verbindungsproblemen
|
||||
- Speichereffizientere Datenverarbeitung
|
||||
- Datenschutz-Hinweis fur Datei-Logging hinzugefugt
|
||||
- Datenschutz-Hinweis für Datei-Logging hinzugefügt
|
||||
|
||||
13
fastlane/metadata/android/de-DE/changelogs/21.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
🛠️ v1.8.1 — CHECKLISTEN & SYNC FIXES
|
||||
|
||||
• Behoben: Sortierung ging beim Öffnen verloren
|
||||
• Behoben: Widget-Scroll bei 3x2 defekt
|
||||
• Behoben: Toast bei Auto-Sync & Drag-Flackern
|
||||
• Neu: Widget-Checklisten mit Sortierung
|
||||
• Neu: Checklisten-Sortierung in Vorschau
|
||||
• Neu: Auto-Scroll bei Zeilenumbruch
|
||||
• Verbessert: Sync-Ratenlimit & Akkuschutz
|
||||
• Verbessert: Toasts → Banner-System
|
||||
• Verbessert: ProGuard für Widgets & Compose
|
||||
|
||||
https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.de.md
|
||||
@@ -1,12 +1,12 @@
|
||||
v1.2.2 - Rückwärtskompatibilität für v1.2.0 User
|
||||
|
||||
Kritische Fehlerbehebung
|
||||
• Server-Wiederherstellung findet jetzt ALLE Notizen (Root + /notes/)
|
||||
• User die von v1.2.0 upgraden verlieren keine Daten mehr
|
||||
• Alte Notizen aus Root-Ordner werden beim Restore gefunden
|
||||
• Server-Restore findet jetzt ALLE Notizen (Root + /notes/)
|
||||
• Upgrade von v1.2.0 ohne Datenverlust
|
||||
• Alte Notizen aus Root-Ordner werden gefunden
|
||||
|
||||
Technische Details
|
||||
• Dual-Mode Download nur bei Server-Restore aktiv
|
||||
• Normale Syncs bleiben schnell (scannen nur /notes/)
|
||||
• Normale Syncs bleiben schnell (nur /notes/)
|
||||
• Automatische Deduplication verhindert Duplikate
|
||||
• Sanfte Migration: Neue Uploads gehen in /notes/, alte bleiben lesbar
|
||||
• Sanfte Migration: Uploads → /notes/, alte bleiben lesbar
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features.
|
||||
|
||||
Hauptfunktionen:
|
||||
• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop)
|
||||
• NEU: Raster-Ansicht (Grid View) für Notizen
|
||||
• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop, Sortierung)
|
||||
• Raster- und Listen-Ansicht mit Notizfarben
|
||||
• Homescreen-Widgets (Quick-Note, Checkliste mit interaktiven Checkboxen)
|
||||
• Multi-Device Sync (Handy, Tablet, Desktop)
|
||||
• WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.)
|
||||
• Markdown-Export und Import für Desktop-Editoren (Obsidian, VS Code)
|
||||
• NEU: WiFi-only Sync, VPN-Unterstützung, Verschlüsselung für lokale Backups
|
||||
• WiFi-only Sync, VPN-Unterstützung, parallele Downloads
|
||||
• Konfigurierbare Sync-Trigger: onSave, onResume, WiFi, periodisch, Boot
|
||||
• Komplett offline nutzbar
|
||||
• Keine Werbung, keine Tracker
|
||||
• Komplett offline nutzbar – keine Werbung, keine Tracker
|
||||
|
||||
Datenschutz & Sicherheit:
|
||||
• Alle Daten bleiben bei dir – keine Cloud, keine Tracking-Bibliotheken
|
||||
• Unterstützung für selbstsignierte SSL-Zertifikate (Self-signed SSL)
|
||||
• SHA-256 Hash des Signaturzertifikats in App und Releases sichtbar
|
||||
• Unterstützung für selbstsignierte SSL-Zertifikate
|
||||
• Verschlüsselte lokale Backups
|
||||
|
||||
Synchronisation:
|
||||
• Automatisch oder manuell, optimierte Performance, periodischer Sync optional
|
||||
• Intelligente Konfliktlösung, Lösch-Tracking, Batch-Aktionen
|
||||
• Parallele Downloads (bis zu 5 gleichzeitig)
|
||||
• Live Sync-Fortschritt mit Phasen-Anzeige
|
||||
• Intelligente Konfliktlösung, Server-Löschungs-Erkennung
|
||||
• Post-Update Changelog-Dialog
|
||||
|
||||
UI & Design:
|
||||
• Moderne Jetpack Compose Oberfläche
|
||||
• Material Design 3, Dynamic Colors, Dark Mode
|
||||
• Animationen und Live Sync-Status
|
||||
• Notiz- und Checklisten-Sortierung (Titel, Datum, Farbe, alphabetisch)
|
||||
|
||||
Mehrsprachig:
|
||||
• Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 140 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 199 KiB |
8
fastlane/metadata/android/en-US/changelogs/1.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
• Material Design 3 with Dynamic Colors
|
||||
• Swipe-to-Delete with confirmation dialog
|
||||
• Server Backup & Restore feature
|
||||
• Improved Empty State view
|
||||
• German localization
|
||||
• Splash Screen Support (Android 12+)
|
||||
• Performance improvements
|
||||
• Bug fixes
|
||||
5
fastlane/metadata/android/en-US/changelogs/2.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
• Configurable sync interval (15/30/60 minutes)
|
||||
• Transparent battery usage display (measured: 0.4%/day at 30min)
|
||||
• Doze Mode optimizations for more reliable background syncs
|
||||
• About section with app information and GitHub links
|
||||
• Various bugfixes and performance improvements
|
||||
13
fastlane/metadata/android/en-US/changelogs/21.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
🛠️ v1.8.1 — CHECKLIST & SYNC FIXES
|
||||
|
||||
• Fixed: Sort lost when reopening checklists
|
||||
• Fixed: Widget scroll on 3x2 size
|
||||
• Fixed: Toast on auto-sync & drag flicker
|
||||
• New: Widget checklists with sorting & separator
|
||||
• New: Checklist sorting in main preview
|
||||
• New: Auto-scroll on line wrap in editor
|
||||
• Improved: Sync rate-limiting & battery save
|
||||
• Improved: Toasts → unified Banner system
|
||||
• Improved: ProGuard for Widgets & Compose
|
||||
|
||||
https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md
|
||||
@@ -1,29 +1,31 @@
|
||||
Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features.
|
||||
|
||||
Key Features:
|
||||
• Text notes and checklists (tap-to-check, drag & drop)
|
||||
• NEW: Grid view for notes
|
||||
• Text notes and checklists (tap-to-check, drag & drop, sorting)
|
||||
• Grid and list view with note color support
|
||||
• Homescreen widgets (quick-note, checklist with interactive checkboxes)
|
||||
• Multi-device sync (phone, tablet, desktop)
|
||||
• WebDAV sync with your own server (Nextcloud, ownCloud, etc.)
|
||||
• Markdown export/import for desktop editors (Obsidian, VS Code)
|
||||
• NEW: WiFi-only sync, VPN support, encryption for local backups
|
||||
• WiFi-only sync, VPN support, parallel downloads
|
||||
• Configurable sync triggers: onSave, onResume, WiFi, periodic, boot
|
||||
• Fully usable offline
|
||||
• No ads, no trackers
|
||||
• Fully usable offline – no ads, no trackers
|
||||
|
||||
Privacy & Security:
|
||||
• Your data stays with you – no cloud, no tracking libraries
|
||||
• Support for self-signed SSL certificates
|
||||
• SHA-256 hash of signing certificate shown in app and releases
|
||||
• Encrypted local backups
|
||||
|
||||
Synchronization:
|
||||
• Automatic or manual, optimized performance, optional periodic sync
|
||||
• Smart conflict resolution, deletion tracking, batch actions
|
||||
• Parallel downloads (up to 5 simultaneous)
|
||||
• Live sync progress with phase indicators
|
||||
• Smart conflict resolution, server deletion detection
|
||||
• Post-update changelog dialog
|
||||
|
||||
UI & Design:
|
||||
• Modern Jetpack Compose interface
|
||||
• Material Design 3, dynamic colors, dark mode
|
||||
• Animations and live sync status
|
||||
• Note & checklist sorting (title, date, color, alphabetical)
|
||||
|
||||
Multilingual:
|
||||
• English and German, automatic detection, in-app language selector
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 140 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 199 KiB |