Release v1.8.1: Checklist Sorting, Sync Improvements & UX Polish

This commit is contained in:
inventory69
2026-02-11 22:01:22 +01:00
68 changed files with 1882 additions and 405 deletions

View File

@@ -61,11 +61,11 @@ jobs:
run: | run: |
mkdir -p apk-output mkdir -p apk-output
# Standard Flavor - Universal APK # Standard Flavor
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \ cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.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 \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk

View File

@@ -18,7 +18,7 @@ jobs:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
- name: Gradle Cache - name: Gradle Cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -69,14 +69,14 @@ jobs:
continue-on-error: true continue-on-error: true
- name: Build-Ergebnis pruefen - name: Build-Ergebnis pruefen
run: | 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" echo "✅ Standard Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/standard/debug/*.apk ls -lh android/app/build/outputs/apk/standard/debug/*.apk
else else
echo "❌ Standard Debug APK Build fehlgeschlagen" echo "❌ Standard Debug APK Build fehlgeschlagen"
exit 1 exit 1
fi 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" echo "✅ F-Droid Debug APK erfolgreich gebaut"
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
else else

View File

@@ -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 (110150dp 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 ## [1.8.0] - 2026-02-10
### 🚨 CRITICAL BUGFIX (Tag neu erstellt) ### 🚨 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.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.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 [1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1

View File

@@ -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 (110150dp 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 ## [1.8.0] - 2026-02-10
### 🚨 CRITICAL BUGFIX (Tag recreated) ### 🚨 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.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.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 [1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1

View File

@@ -94,10 +94,10 @@ Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes
Dokumentations-Verbesserungen sind auch Contributions! Dokumentations-Verbesserungen sind auch Contributions!
**Dateien:** **Dateien:**
- `README.md` / `README.en.md` - Übersicht - `README.de.md` / `README.md` - Übersicht
- `QUICKSTART.md` / `QUICKSTART.en.md` - Schritt-für-Schritt Anleitung - `QUICKSTART.de.md` / `QUICKSTART.md` - Schritt-für-Schritt Anleitung
- `DOCS.md` / `DOCS.en.md` - Technische Details - `docs/DOCS.de.md` / `docs/DOCS.md` - Technische Details
- `server/README.md` / `server/README.en.md` - Server Setup - `server/README.de.md` / `server/README.md` - Server Setup
**Bitte:** Halte beide Sprachen (DE/EN) synchron! **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! Documentation improvements are also contributions!
**Files:** **Files:**
- `README.md` / `README.en.md` - Overview - `README.de.md` / `README.md` - Overview
- `QUICKSTART.md` / `QUICKSTART.en.md` - Step-by-step guide - `QUICKSTART.de.md` / `QUICKSTART.md` - Step-by-step guide
- `DOCS.md` / `DOCS.en.md` - Technical details - `docs/DOCS.de.md` / `docs/DOCS.md` - Technical details
- `server/README.md` / `server/README.en.md` - Server setup - `server/README.de.md` / `server/README.md` - Server setup
**Please:** Keep both languages (DE/EN) in sync! **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). Ö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! 🚀**

View File

@@ -8,7 +8,7 @@
## Voraussetzungen ## Voraussetzungen
- ✅ Android 8.0+ Smartphone/Tablet - ✅ Android 7.0+ Smartphone/Tablet
- ✅ WLAN-Verbindung - ✅ WLAN-Verbindung
- ✅ Eigener Server mit Docker (optional - für Self-Hosting) - ✅ 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 ### Schritt 2: App installieren
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest) 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:** 2. **Installation erlauben:**
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren - Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
@@ -261,7 +261,7 @@ Für zuverlässigen Auto-Sync:
## 🆘 Weitere Hilfe ## 🆘 Weitere Hilfe
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues) - **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) - **Server Setup Details:** [server/README.md](server/README.md)
--- ---

View File

@@ -8,7 +8,7 @@
## Prerequisites ## Prerequisites
- ✅ Android 8.0+ smartphone/tablet - ✅ Android 7.0+ smartphone/tablet
- ✅ WiFi connection - ✅ WiFi connection
- ✅ Own server with Docker (optional - for self-hosting) - ✅ 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 ### Step 2: Install App
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest) 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:** 2. **Allow installation:**
- Android: Settings → Security → Enable "Unknown sources" for your browser - 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. > **💡 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 - ✅ Success? → Continue to step 4
- ❌ Error? → See [Troubleshooting](#troubleshooting) - ❌ Error? → See [Troubleshooting](#troubleshooting)
@@ -261,8 +261,8 @@ For reliable auto-sync:
## 🆘 Further Help ## 🆘 Further Help
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues) - **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Complete docs:** [DOCS.en.md](DOCS.en.md) - **Complete docs:** [DOCS.md](docs/DOCS.md)
- **Server setup details:** [server/README.en.md](server/README.en.md) - **Server setup details:** [server/README.md](server/README.md)
--- ---

View File

@@ -8,7 +8,7 @@
<div align="center"> <div align="center">
[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) [![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) [![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/)
[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/) [![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/)
[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) [![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/)
@@ -66,12 +66,14 @@ alt="Get it on F-Droid" align="center" height="80" /></a>
- 📝 **Offline-first** Funktioniert ohne Internet - 📝 **Offline-first** Funktioniert ohne Internet
- 📊 **Flexible Ansichten** Listen- und Grid-Layout - 📊 **Flexible Ansichten** Listen- und Grid-Layout
-**Checklisten** Tap-to-Check, Drag & Drop -**Checklisten** Tap-to-Check, Drag & Drop
- 🌍 **Mehrsprachig** Deutsch/Englisch mit Sprachauswahl
- 🔄 **Konfigurierbare Sync-Trigger** onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot - 🔄 **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) - 🔒 **Self-hosted** Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** Export/Import als JSON-Datei (optional verschlüsselt) - 💾 **Lokales Backup** Export/Import als JSON-Datei (optional verschlüsselt)
- 🖥️ **Desktop-Integration** Markdown-Export für Obsidian, VS Code, Typora - 🖥️ **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 - 🎨 **Material Design 3** Dynamischer Dark/Light Mode & Farben
➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md) ➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
@@ -138,6 +140,6 @@ MIT License siehe [LICENSE](LICENSE)
<div align="center"> <div align="center">
<br /><br /> <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> </div>

View File

@@ -8,7 +8,7 @@
<div align="center"> <div align="center">
[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) [![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) [![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/)
[![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/) [![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/)
[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) [![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/)
@@ -66,12 +66,14 @@ alt="Get it on F-Droid" align="center" height="80" /></a>
- 📝 **Offline-first** - Works without internet - 📝 **Offline-first** - Works without internet
- 📊 **Flexible views** - Switch between list and grid layout - 📊 **Flexible views** - Switch between list and grid layout
-**Checklists** - Tap-to-check, drag & drop -**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 - 🔄 **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) - 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file (encryption available) - 💾 **Local backup** - Export/Import as JSON file (encryption available)
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora - 🖥️ **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 - 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md) ➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
@@ -148,6 +150,6 @@ MIT License - see [LICENSE](LICENSE)
<div align="center"> <div align="center">
<br /><br /> <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> </div>

View File

@@ -20,8 +20,8 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 20 // 🎉 v1.8.0: Widgets, Sorting, UI Polish, Post-Update Changelog versionCode = 21 // 🐛 v1.8.1: Checklist Fixes, Widget Sorting, ProGuard Audit
versionName = "1.8.0" // 🎉 v1.8.0: Major Feature Release versionName = "1.8.1" // 🐛 v1.8.1: Bugfix & Polish Release
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -77,3 +77,23 @@
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions # v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
# This class only exists on API 35+ but Compose handles the fallback gracefully # This class only exists on API 35+ but Compose handles the fallback gracefully
-dontwarn android.text.Layout$TextInclusionStrategy -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 { *; }

View File

@@ -24,7 +24,9 @@ data class Note(
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY, val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder // v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT, 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 * Serialisiert Note zu JSON
@@ -71,13 +73,20 @@ data class Note(
* v1.4.0: Unterstützt jetzt auch Checklisten-Format * v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/ */
fun toMarkdown(): String { fun toMarkdown(): String {
// 🆕 v1.8.1 (IMPL_03): Sortierung im Frontmatter
val sortLine = if (noteType == NoteType.CHECKLIST && checklistSortOption != null) {
"\nsort: $checklistSortOption"
} else {
""
}
val header = """ val header = """
--- ---
id: $id id: $id
created: ${formatISO8601(createdAt)} created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)} updated: ${formatISO8601(updatedAt)}
device: $deviceId device: $deviceId
type: ${noteType.name.lowercase()} type: ${noteType.name.lowercase()}$sortLine
--- ---
# $title # $title
@@ -119,6 +128,14 @@ type: ${noteType.name.lowercase()}
NoteType.TEXT 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 // Parsen der Basis-Note
val rawNote = gson.fromJson(json, NoteRaw::class.java) val rawNote = gson.fromJson(json, NoteRaw::class.java)
@@ -158,7 +175,8 @@ type: ${noteType.name.lowercase()}
deviceId = rawNote.deviceId, deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY, syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType, noteType = noteType,
checklistItems = checklistItems checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}") Logger.w(TAG, "Failed to parse JSON: ${e.message}")
@@ -246,6 +264,9 @@ type: ${noteType.name.lowercase()}
else -> NoteType.TEXT 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 // v1.4.0: Parse Content basierend auf Typ
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest // FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") } val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
@@ -300,7 +321,8 @@ type: ${noteType.name.lowercase()}
deviceId = metadata["device"] ?: "desktop", deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType, noteType = noteType,
checklistItems = checklistItems checklistItems = checklistItems,
checklistSortOption = checklistSortOption // 🆕 v1.8.1 (IMPL_03)
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}") Logger.w(TAG, "Failed to parse Markdown: ${e.message}")

View File

@@ -52,9 +52,10 @@ data class SyncProgress(
/** /**
* Ob das Banner sichtbar sein soll * Ob das Banner sichtbar sein soll
* Silent syncs zeigen nie ein Banner * Silent syncs zeigen nie ein Banner
* 🆕 v1.8.1 (IMPL_12): INFO ist immer sichtbar (nicht vom silent-Flag betroffen)
*/ */
val isVisible: Boolean 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) * Ob gerade ein aktiver Sync läuft (mit Spinner)
@@ -95,5 +96,8 @@ enum class SyncPhase {
COMPLETED, COMPLETED,
/** Sync mit Fehler abgebrochen */ /** Sync mit Fehler abgebrochen */
ERROR ERROR,
/** 🆕 v1.8.1 (IMPL_12): Kurzfristige Info-Meldung (nicht sync-bezogen) */
INFO
} }

View File

@@ -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")
}
}
} }

View File

@@ -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) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -104,7 +105,42 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { 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 // 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
@@ -122,7 +158,7 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { 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) // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
@@ -143,7 +179,7 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { 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 // ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
@@ -167,7 +203,7 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { 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") Logger.d(TAG, " SyncService: $syncService")
} }
@@ -188,7 +224,7 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Processing result") Logger.d(TAG, "📍 Step 7: Processing result")
Logger.d( Logger.d(
TAG, TAG,
"📦 Sync result: success=${result.isSuccess}, " + "📦 Sync result: success=${result.isSuccess}, " +
@@ -198,10 +234,13 @@ class SyncWorker(
if (result.isSuccess) { if (result.isSuccess) {
if (BuildConfig.DEBUG) { 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") 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 // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt) // UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
if (result.syncedCount > 0) { if (result.syncedCount > 0) {
@@ -248,9 +287,13 @@ class SyncWorker(
Result.success() Result.success()
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Failure path") Logger.d(TAG, "📍 Step 8: Failure path")
} }
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}") Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
SyncStateManager.markError(result.errorMessage)
NotificationHelper.showSyncError( NotificationHelper.showSyncError(
applicationContext, applicationContext,
result.errorMessage ?: "Unbekannter Fehler" result.errorMessage ?: "Unbekannter Fehler"

View File

@@ -27,6 +27,16 @@ class WifiSyncReceiver : BroadcastReceiver() {
return 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) // Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToWifi(context)) { if (isConnectedToWifi(context)) {
scheduleSyncWork(context) scheduleSyncWork(context)

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
* v1.5.0: NoteEditor Redesign * v1.5.0: NoteEditor Redesign
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag) * v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag)
* v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt) * 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( class DragDropListState(
private val state: LazyListState, private val state: LazyListState,
@@ -36,8 +37,14 @@ class DragDropListState(
private var draggingItemDraggedDelta by mutableFloatStateOf(0f) private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset 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) 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 val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item -> get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
@@ -47,9 +54,28 @@ class DragDropListState(
get() = state.layoutInfo.visibleItemsInfo get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex } .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) { fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex draggingItemIndex = itemIndex
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f val info = draggingItemLayoutInfo
draggingItemInitialOffset = info?.offset?.toFloat() ?: 0f
draggingItemSize = info?.size ?: 0
draggingItemDraggedDelta = 0f draggingItemDraggedDelta = 0f
} }
@@ -57,6 +83,7 @@ class DragDropListState(
draggingItemDraggedDelta = 0f draggingItemDraggedDelta = 0f
draggingItemIndex = null draggingItemIndex = null
draggingItemInitialOffset = 0f draggingItemInitialOffset = 0f
draggingItemSize = 0
overscrollJob?.cancel() overscrollJob?.cancel()
} }
@@ -65,15 +92,19 @@ class DragDropListState(
val draggingItem = draggingItemLayoutInfo ?: return val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset 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 // 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"), // 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. // wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
// Dies verhindert Oszillation bei Items unterschiedlicher Größe. // 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 -> 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 { run {
val targetCenter = item.offset + item.size / 2 val targetCenter = item.offset + item.size / 2
startOffset < targetCenter && endOffset > targetCenter startOffset < targetCenter && endOffset > targetCenter
@@ -89,15 +120,19 @@ class DragDropListState(
null 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) { if (scrollToIndex != null) {
scope.launch { scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) 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) // 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
draggingItemIndex = targetItem.index draggingItemIndex = targetItem.index
} }
} else { } else {
onMove(draggingItem.index, targetItem.index) onMove(fromDataIndex, toDataIndex)
draggingItemIndex = targetItem.index draggingItemIndex = targetItem.index
} }
} else { } 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") @Suppress("UnusedPrivateProperty")
private val LazyListItemInfo.offsetEnd: Int private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size get() = this.offset + this.size

View File

@@ -1,11 +1,6 @@
package dev.dettmer.simplenotes.ui.editor package dev.dettmer.simplenotes.ui.editor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -19,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -70,8 +66,10 @@ import dev.dettmer.simplenotes.utils.showToast
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val LAYOUT_DELAY_MS = 100L 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 ITEM_CORNER_RADIUS_DP = 8
private const val DRAGGING_ITEM_Z_INDEX = 10f private const val DRAGGING_ITEM_Z_INDEX = 10f
private val DRAGGING_ELEVATION_DP = 8.dp
/** /**
* Main Composable for the Note Editor screen. * 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 @Suppress("LongParameterList") // Compose functions commonly have many callback parameters
@Composable @Composable
private fun ChecklistEditor( private fun ChecklistEditor(
@@ -351,6 +409,9 @@ private fun ChecklistEditor(
onMove = onMove 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 // 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen
val uncheckedCount = items.count { !it.isChecked } val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked } val checkedCount = items.count { it.isChecked }
@@ -359,70 +420,78 @@ private fun ChecklistEditor(
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0 val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
Column(modifier = modifier) { 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( LazyColumn(
state = listState, state = listState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
itemsIndexed( itemsIndexed(
items = items, items = if (showSeparator) items.subList(0, uncheckedCount) else items,
key = { _, item -> item.id } key = { _, item -> item.id }
) { index, item -> ) { index, item ->
// 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item DraggableChecklistItem(
if (showSeparator && index == uncheckedCount) { item = item,
CheckedItemsSeparator(checkedCount = checkedCount) visualIndex = index,
} dragDropState = dragDropState,
focusNewItemId = focusNewItemId,
val isDragging = dragDropState.draggingItemIndex == index onTextChange = onTextChange,
val elevation by animateDpAsState( onCheckedChange = onCheckedChange,
targetValue = if (isDragging) 8.dp else 0.dp, onDelete = onDelete,
label = "elevation" onAddNewItemAfter = onAddNewItemAfter,
onFocusHandled = onFocusHandled,
onHeightChanged = { scrollToItemIndex = index } // 🆕 v1.8.1 (IMPL_05)
) )
}
val shouldFocus = item.id == focusNewItemId // 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
if (showSeparator) {
// v1.5.0: Clear focus request after handling item(key = "separator") {
LaunchedEffect(shouldFocus) { CheckedItemsSeparator(
if (shouldFocus) { checkedCount = checkedCount,
onFocusHandled() isDragActive = dragDropState.draggingItemIndex != null
} )
} }
// 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge // 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
AnimatedVisibility( itemsIndexed(
visible = true, items = items.subList(uncheckedCount, items.size),
enter = fadeIn() + slideInVertically(), key = { _, item -> item.id }
exit = fadeOut() + slideOutVertically() ) { index, item ->
) { val visualIndex = uncheckedCount + 1 + index // +1 für Separator
ChecklistItemRow( DraggableChecklistItem(
item = item, item = item,
onTextChange = { onTextChange(item.id, it) }, visualIndex = visualIndex,
onCheckedChange = { onCheckedChange(item.id, it) }, dragDropState = dragDropState,
onDelete = { onDelete(item.id) }, focusNewItemId = focusNewItemId,
onAddNewItem = { onAddNewItemAfter(item.id) }, onTextChange = onTextChange,
requestFocus = shouldFocus, onCheckedChange = onCheckedChange,
// 🆕 v1.8.0: IMPL_023 - Drag state übergeben onDelete = onDelete,
isDragging = isDragging, onAddNewItemAfter = onAddNewItemAfter,
// 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden onFocusHandled = onFocusHandled,
isAnyItemDragging = dragDropState.draggingItemIndex != null, onHeightChanged = { scrollToItemIndex = visualIndex } // 🆕 v1.8.1 (IMPL_05)
// 🆕 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)
)
) )
} }
} }

View File

@@ -13,6 +13,7 @@ import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
@@ -90,65 +91,93 @@ class NoteEditorViewModel(
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
if (noteId != null) { if (noteId != null) {
// Load existing note loadExistingNote(noteId)
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)
}
}
} else { } else {
// New note initNewNote(noteTypeString)
currentNoteType = try { }
NoteType.valueOf(noteTypeString) }
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
private fun loadExistingNote(noteId: String) {
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
noteType = currentNoteType, title = note.title,
isNewNote = true, content = note.content,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) { noteType = note.noteType,
ToolbarTitle.NEW_CHECKLIST isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else { } else {
ToolbarTitle.NEW_NOTE ToolbarTitle.EDIT_NOTE
} }
) )
} }
// Add first empty item for new checklists if (note.noteType == NoteType.CHECKLIST) {
if (currentNoteType == NoteType.CHECKLIST) { loadChecklistData(note)
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
} }
} }
} }
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 // Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -173,11 +202,28 @@ class NoteEditorViewModel(
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten. * 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten. * 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> { private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
val unchecked = items.filter { !it.isChecked } val sorted = when (_lastChecklistSortOption.value) {
val checked = items.filter { it.isChecked } 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) 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 { fun addChecklistItemAfter(afterItemId: String): String {
val newItem = ChecklistItemState.createEmpty(0) val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items -> _checklistItems.update { items ->
val index = items.indexOfFirst { it.id == afterItemId } val index = items.indexOfFirst { it.id == afterItemId }
if (index >= 0) { 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() val newList = items.toMutableList()
newList.add(index + 1, newItem) newList.add(effectiveIndex, newItem)
// Update order values // Update order values
newList.mapIndexed { i, item -> item.copy(order = i) } newList.mapIndexed { i, item -> item.copy(order = i) }
} else { } else {
@@ -214,12 +281,46 @@ class NoteEditorViewModel(
return newItem.id 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 { fun addChecklistItemAtEnd(): String {
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size) val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items -> items + newItem } _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 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) { fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items -> _checklistItems.update { items ->
val filtered = items.filter { it.id != itemId } val filtered = items.filter { it.id != itemId }
@@ -238,15 +339,18 @@ class NoteEditorViewModel(
val fromItem = items.getOrNull(fromIndex) ?: return@update items val fromItem = items.getOrNull(fromIndex) ?: return@update items
val toItem = items.getOrNull(toIndex) ?: return@update items val toItem = items.getOrNull(toIndex) ?: return@update items
// 🆕 v1.8.0 (IMPL_017): Drag nur innerhalb der gleichen Gruppe erlauben
// (checked ↔ checked, unchecked ↔ unchecked)
if (fromItem.isChecked != toItem.isChecked) {
return@update items // Kein Move über Gruppen-Grenze
}
val mutableList = items.toMutableList() val mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex) val item = mutableList.removeAt(fromIndex)
mutableList.add(toIndex, item)
// 🆕 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 // Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) } mutableList.mapIndexed { index, i -> i.copy(order = index) }
} }
@@ -348,6 +452,7 @@ class NoteEditorViewModel(
content = "", // Empty for checklists content = "", // Empty for checklists
noteType = NoteType.CHECKLIST, noteType = NoteType.CHECKLIST,
checklistItems = validItems, checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING syncStatus = SyncStatus.PENDING
) )
@@ -357,6 +462,7 @@ class NoteEditorViewModel(
content = "", content = "",
noteType = NoteType.CHECKLIST, noteType = NoteType.CHECKLIST,
checklistItems = validItems, checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
deviceId = DeviceIdGenerator.getDeviceId(getApplication()), deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY 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 // 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync() triggerOnSaveSync()
@@ -406,17 +512,33 @@ class NoteEditorViewModel(
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId) webdavService.deleteNoteFromServer(noteId)
} }
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
if (success) { if (success) {
Logger.d(TAG, "Note $noteId deleted from server") Logger.d(TAG, "Note $noteId deleted from server")
SyncStateManager.showInfo(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
)
)
} else { } else {
Logger.w(TAG, "Failed to delete note $noteId from server") 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) { } catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}") 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) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }
@@ -513,6 +635,7 @@ class NoteEditorViewModel(
Logger.d(TAG, "📤 Triggering onSave sync") Logger.d(TAG, "📤 Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>() val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(Constants.SYNC_WORK_TAG) .addTag(Constants.SYNC_WORK_TAG)
.addTag(Constants.SYNC_ONSAVE_TAG) // 🆕 v1.8.1 (IMPL_08B): Bypassed globalen Cooldown
.build() .build()
WorkManager.getInstance(getApplication()).enqueue(syncRequest) WorkManager.getInstance(getApplication()).enqueue(syncRequest)
} }

View File

@@ -15,6 +15,7 @@ import dev.dettmer.simplenotes.R
/** /**
* 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items * 🆕 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: * Zeigt eine dezente Linie mit Anzahl der erledigten Items:
* ── 3 completed ── * ── 3 completed ──
@@ -22,7 +23,8 @@ import dev.dettmer.simplenotes.R
@Composable @Composable
fun CheckedItemsSeparator( fun CheckedItemsSeparator(
checkedCount: Int, checkedCount: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isDragActive: Boolean = false // 🆕 v1.8.1 IMPL_14
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -32,7 +34,10 @@ fun CheckedItemsSeparator(
) { ) {
HorizontalDivider( HorizontalDivider(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant color = if (isDragActive)
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.outlineVariant
) )
Text( Text(
@@ -42,13 +47,19 @@ fun CheckedItemsSeparator(
checkedCount checkedCount
), ),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = if (isDragActive)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 12.dp) modifier = Modifier.padding(horizontal = 12.dp)
) )
HorizontalDivider( HorizontalDivider(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant color = if (isDragActive)
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.outlineVariant
) )
} }
} }

View File

@@ -24,8 +24,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -71,6 +71,7 @@ fun ChecklistItemRow(
isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state
isAnyItemDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Hide gradient during any drag 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 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 modifier: Modifier = Modifier
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@@ -92,17 +93,14 @@ fun ChecklistItemRow(
// 🆕 v1.8.0: ScrollState für dynamischen Gradient // 🆕 v1.8.0: ScrollState für dynamischen Gradient
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
// 🆕 v1.8.0: Scroll-basierter Ansatz aktiv wenn Höhe berechnet wurde // 🆕 v1.8.1: IMPL_05 - Letzte Zeilenanzahl tracken für Auto-Scroll
val useScrollClipping = hasOverflow && collapsedHeightDp != null var lastLineCount by remember { mutableIntStateOf(0) }
// 🆕 v1.8.0: Dynamische Gradient-Sichtbarkeit basierend auf Scroll-Position // 🆕 v1.8.1: Gradient-Sichtbarkeit direkt berechnet (kein derivedStateOf)
val showGradient = useScrollClipping && !isFocused && !isAnyItemDragging // derivedStateOf mit remember{} fängt showGradient als stale val — nie aktualisiert.
val showTopGradient by remember { val showGradient = hasOverflow && collapsedHeightDp != null && !isFocused && !isAnyItemDragging
derivedStateOf { showGradient && scrollState.value > 0 } val showTopGradient = showGradient && scrollState.value > 0
} val showBottomGradient = showGradient && scrollState.value < scrollState.maxValue
val showBottomGradient by remember {
derivedStateOf { showGradient && scrollState.value < scrollState.maxValue }
}
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items) // v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
LaunchedEffect(requestFocus) { LaunchedEffect(requestFocus) {
@@ -173,7 +171,7 @@ fun ChecklistItemRow(
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
// Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed // Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
Box( Box(
modifier = if (!isFocused && useScrollClipping) { modifier = if (!isFocused && hasOverflow && collapsedHeightDp != null) {
Modifier Modifier
.heightIn(max = collapsedHeightDp!!) .heightIn(max = collapsedHeightDp!!)
.verticalScroll(scrollState) .verticalScroll(scrollState)
@@ -216,13 +214,16 @@ fun ChecklistItemRow(
onNext = { onAddNewItem() } onNext = { onAddNewItem() }
), ),
singleLine = false, singleLine = false,
// maxLines nur als Fallback bis collapsedHeight berechnet ist // 🆕 v1.8.1: maxLines IMMER Int.MAX_VALUE — keine Oszillation möglich
maxLines = if (isFocused || useScrollClipping) Int.MAX_VALUE else COLLAPSED_MAX_LINES, // 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), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
onTextLayout = { textLayoutResult -> 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) { if (!isAnyItemDragging) {
val overflow = textLayoutResult.lineCount > COLLAPSED_MAX_LINES val overflow = lineCount > COLLAPSED_MAX_LINES
hasOverflow = overflow hasOverflow = overflow
// Höhe der ersten 5 Zeilen berechnen (einmalig) // Höhe der ersten 5 Zeilen berechnen (einmalig)
if (overflow && collapsedHeightDp == null) { if (overflow && collapsedHeightDp == null) {
@@ -230,7 +231,16 @@ fun ChecklistItemRow(
textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp() 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 -> decorationBox = { innerTextField ->
Box { Box {

View File

@@ -236,6 +236,11 @@ class ComposeMainActivity : ComponentActivity() {
kotlinx.coroutines.delay(2000L) kotlinx.coroutines.delay(2000L)
SyncStateManager.reset() 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 -> { dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
kotlinx.coroutines.delay(4000L) kotlinx.coroutines.delay(4000L)
SyncStateManager.reset() SyncStateManager.reset()

View File

@@ -470,12 +470,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
if (success) { 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 { } else {
_showToast.emit(getString(R.string.snackbar_server_delete_failed)) SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
} }
} catch (e: Exception) { } catch (e: Exception) {
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: "")) SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
} finally { } finally {
// Remove from pending deletions // Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - noteId _pendingDeletions.value = _pendingDeletions.value - noteId
@@ -507,7 +508,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// Show aggregated toast // 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
val message = when { val message = when {
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount) failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
successCount == 0 -> getString(R.string.snackbar_server_delete_failed) successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
@@ -517,7 +518,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
successCount + failCount successCount + failCount
) )
} }
_showToast.emit(message) if (failCount > 0) {
SyncStateManager.showError(message)
} else {
SyncStateManager.showInfo(message)
}
} }
} }
@@ -555,6 +560,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return 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.7.0: Feedback wenn Sync bereits läuft
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant // 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
if (!SyncStateManager.tryStartSync(source)) { if (!SyncStateManager.tryStartSync(source)) {
@@ -571,6 +583,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch { viewModelScope.launch {
try { try {
// Check for unsynced changes (Banner zeigt bereits PREPARING) // Check for unsynced changes (Banner zeigt bereits PREPARING)
@@ -636,7 +651,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return 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()) { if (!canTriggerAutoSync()) {
return return
} }
@@ -665,6 +685,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Update last sync timestamp // Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply() 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 { viewModelScope.launch {
try { try {
// Check for unsynced changes // Check for unsynced changes
@@ -692,9 +715,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
// Silent Sync mit echten Änderungen → trotzdem markCompleted (wird silent behandelt) // 🆕 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)) SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes() loadNotes()
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") Logger.d(TAG, " Auto-sync ($source): No changes")

View File

@@ -1,8 +1,6 @@
package dev.dettmer.simplenotes.ui.main package dev.dettmer.simplenotes.ui.main
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -32,7 +30,6 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R

View File

@@ -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}"
}
}

View File

@@ -149,11 +149,10 @@ fun NoteCardCompact(
text = when (note.noteType) { text = when (note.noteType) {
NoteType.TEXT -> note.content NoteType.TEXT -> note.content
NoteType.CHECKLIST -> { NoteType.CHECKLIST -> {
note.checklistItems // 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
?.joinToString("\n") { item -> note.checklistItems?.let { items ->
val prefix = if (item.isChecked) "" else "" generateChecklistPreview(items, note.checklistSortOption)
"$prefix ${item.text}" } ?: ""
} ?: ""
} }
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View File

@@ -163,11 +163,10 @@ fun NoteCardGrid(
text = when (note.noteType) { text = when (note.noteType) {
NoteType.TEXT -> note.content NoteType.TEXT -> note.content
NoteType.CHECKLIST -> { NoteType.CHECKLIST -> {
note.checklistItems // 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
?.joinToString("\n") { item -> note.checklistItems?.let { items ->
val prefix = if (item.isChecked) "" else "" generateChecklistPreview(items, note.checklistSortOption)
"$prefix ${item.text}" } ?: ""
} ?: ""
} }
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -51,11 +52,13 @@ fun SyncProgressBanner(
// Farbe animiert wechseln je nach State // Farbe animiert wechseln je nach State
val isError = progress.phase == SyncPhase.ERROR val isError = progress.phase == SyncPhase.ERROR
val isCompleted = progress.phase == SyncPhase.COMPLETED 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( val backgroundColor by animateColorAsState(
targetValue = when { targetValue = when {
isError -> MaterialTheme.colorScheme.errorContainer isError -> MaterialTheme.colorScheme.errorContainer
isInfo -> MaterialTheme.colorScheme.secondaryContainer // 🆕 v1.8.1 (IMPL_12)
else -> MaterialTheme.colorScheme.primaryContainer else -> MaterialTheme.colorScheme.primaryContainer
}, },
label = "bannerColor" label = "bannerColor"
@@ -64,6 +67,7 @@ fun SyncProgressBanner(
val contentColor by animateColorAsState( val contentColor by animateColorAsState(
targetValue = when { targetValue = when {
isError -> MaterialTheme.colorScheme.onErrorContainer isError -> MaterialTheme.colorScheme.onErrorContainer
isInfo -> MaterialTheme.colorScheme.onSecondaryContainer // 🆕 v1.8.1 (IMPL_12)
else -> MaterialTheme.colorScheme.onPrimaryContainer else -> MaterialTheme.colorScheme.onPrimaryContainer
}, },
label = "bannerContentColor" label = "bannerContentColor"
@@ -89,7 +93,7 @@ fun SyncProgressBanner(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Icon: Spinner (aktiv), Checkmark (completed), Error (error) // Icon: Spinner (aktiv), Checkmark (completed), Error (error), Info (info)
when { when {
isCompleted -> { isCompleted -> {
Icon( Icon(
@@ -99,6 +103,14 @@ fun SyncProgressBanner(
tint = contentColor tint = contentColor
) )
} }
isInfo -> { // 🆕 v1.8.1 (IMPL_12)
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor
)
}
isError -> { isError -> {
Icon( Icon(
imageVector = Icons.Filled.ErrorOutline, 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.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown)
SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed) SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed)
SyncPhase.ERROR -> stringResource(R.string.sync_phase_error) SyncPhase.ERROR -> stringResource(R.string.sync_phase_error)
SyncPhase.INFO -> "" // 🆕 v1.8.1 (IMPL_12): INFO nutzt immer resultMessage
} }
} }

View File

@@ -82,4 +82,11 @@ object Constants {
// 📋 v1.8.0: Post-Update Changelog // 📋 v1.8.0: Post-Update Changelog
const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version" 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"
} }

View File

@@ -36,15 +36,20 @@ class NoteWidget : GlanceAppWidget() {
companion object { companion object {
// Responsive Breakpoints — schmale + breite Spalten // Responsive Breakpoints — schmale + breite Spalten
val SIZE_SMALL = DpSize(110.dp, 80.dp) // Schmal+kurz: nur Titel 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_MEDIUM = DpSize(110.dp, 110.dp) // Schmal+mittel: Vorschau
val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt val SIZE_NARROW_SCROLL = DpSize(110.dp, 150.dp) // 🆕 v1.8.1: Schmal+scroll (Standard 3x2)
val SIZE_WIDE_MEDIUM = DpSize(250.dp, 110.dp) // Breit+mittel: Vorschau val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt
val SIZE_WIDE_LARGE = DpSize(250.dp, 250.dp) // Breit+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( 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 override val stateDefinition = PreferencesGlanceStateDefinition

View File

@@ -5,6 +5,7 @@ import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
@@ -51,14 +52,32 @@ class ToggleChecklistItemAction : ActionCallback {
} else item } else item
} ?: return } ?: 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( val updatedNote = note.copy(
checklistItems = updatedItems, checklistItems = sortedItems,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING syncStatus = SyncStatus.PENDING
) )
storage.saveNote(updatedNote) 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 // 🐛 FIX: Glance-State ändern um Re-Render zu erzwingen
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->

View File

@@ -38,9 +38,11 @@ import androidx.glance.layout.width
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity 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 * 🆕 v1.8.0: Glance Composable Content für das Notiz-Widget
@@ -52,6 +54,7 @@ import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
// ── Size Classification ── // ── Size Classification ──
private val WIDGET_HEIGHT_SMALL_THRESHOLD = 110.dp 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 private val WIDGET_SIZE_MEDIUM_THRESHOLD = 250.dp
// 🆕 v1.8.0: Increased preview lengths for better text visibility // 🆕 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 const val TEXT_PREVIEW_FULL_LENGTH = 300
private fun DpSize.toSizeClass(): WidgetSizeClass = when { private fun DpSize.toSizeClass(): WidgetSizeClass = when {
height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL 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 // 🆕 v1.8.1: Neue ScrollView-Schwelle bei 150dp Höhe
height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_MED width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.NARROW_MED
else -> WidgetSizeClass.WIDE_TALL 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 @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) { when (note.noteType) {
NoteType.TEXT -> TextNoteFullView(note) NoteType.TEXT -> Box(modifier = contentClickModifier) {
NoteType.CHECKLIST -> ChecklistFullView( TextNoteFullView(note)
note = note, }
isLocked = isLocked, NoteType.CHECKLIST -> {
glanceId = glanceId // 🆕 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) { when (note.noteType) {
NoteType.TEXT -> TextNoteFullView(note) NoteType.TEXT -> Box(modifier = contentClickModifier) {
NoteType.CHECKLIST -> ChecklistFullView( TextNoteFullView(note)
note = note, }
isLocked = isLocked, NoteType.CHECKLIST -> {
glanceId = glanceId // 🆕 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, isLocked: Boolean,
glanceId: GlanceId 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 visibleItems = items.take(maxItems)
val remainingCount = items.size - visibleItems.size val remainingCount = items.size - visibleItems.size
val checkedCount = items.count { it.isChecked }
Column(modifier = GlanceModifier.padding(horizontal = 8.dp, vertical = 2.dp)) { Column(modifier = GlanceModifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
var separatorShown = false
visibleItems.forEach { item -> 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) { if (isLocked) {
Row( Row(
modifier = GlanceModifier modifier = GlanceModifier
@@ -385,7 +466,7 @@ private fun ChecklistCompactView(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (item.isChecked) "" else "", text = if (item.isChecked) "☑️" else "", // 🆕 v1.8.1 (IMPL_06)
style = TextStyle(fontSize = 14.sp) style = TextStyle(fontSize = 14.sp)
) )
Spacer(modifier = GlanceModifier.width(6.dp)) Spacer(modifier = GlanceModifier.width(6.dp))
@@ -443,15 +524,41 @@ private fun ChecklistFullView(
isLocked: Boolean, isLocked: Boolean,
glanceId: GlanceId 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( LazyColumn(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
) { ) {
items(items.size) { index -> items(totalItems) { index ->
val item = items[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) { if (isLocked) {
Row( Row(
@@ -461,7 +568,7 @@ private fun ChecklistFullView(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (item.isChecked) "" else "", text = if (item.isChecked) "☑️" else "", // 🆕 v1.8.1 (IMPL_06)
style = TextStyle(fontSize = 16.sp) style = TextStyle(fontSize = 16.sp)
) )
Spacer(modifier = GlanceModifier.width(8.dp)) Spacer(modifier = GlanceModifier.width(8.dp))

View File

@@ -4,11 +4,14 @@ package dev.dettmer.simplenotes.widget
* 🆕 v1.8.0: Size classification for responsive Note Widget layouts * 🆕 v1.8.0: Size classification for responsive Note Widget layouts
* *
* Determines which layout variant to use based on widget dimensions. * 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 { enum class WidgetSizeClass {
SMALL, // Nur Titel SMALL, // Nur Titel
NARROW_MED, // Schmal, Vorschau NARROW_MED, // Schmal, Vorschau (CompactView)
NARROW_TALL, // Schmal, voller Inhalt NARROW_SCROLL, // 🆕 v1.8.1: Schmal, scrollbare Liste (150dp+)
WIDE_MED, // Breit, Vorschau NARROW_TALL, // Schmal, voller Inhalt
WIDE_TALL // Breit, voller Inhalt WIDE_MED, // Breit, Vorschau (CompactView)
WIDE_SCROLL, // 🆕 v1.8.1: Breit, scrollbare Liste (150dp+)
WIDE_TALL // Breit, voller Inhalt
} }

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.editor package dev.dettmer.simplenotes.ui.editor
import dev.dettmer.simplenotes.models.ChecklistSortOption
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
@@ -174,4 +175,204 @@ class ChecklistSortingTest {
assertEquals(1, sorted[1].order) assertEquals(1, sorted[1].order)
assertEquals(2, sorted[2].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)
}
} }

View File

@@ -1,8 +1,8 @@
# ⚡ v1.3.1: detekt Configuration # ⚡ v1.8.1: detekt Configuration
# Pragmatic rules for simple-notes-sync # Pragmatic rules for simple-notes-sync
build: 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 excludeCorrectable: false
config: config:

View File

@@ -276,8 +276,9 @@ Schritt-für-Schritt:
### Daten-Schutz ### Daten-Schutz
-**Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion -**Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion
-**Keine Verschlüsselung** - Klartextformat für Lesbarkeit -**Optionale Verschlüsselung** _(v1.7.0+)_ - Backup-Dateien mit Passwort schützen
- ⚠️ **Sensible Daten?** - Backup-Datei selbst verschlüsseln (z.B. 7-Zip mit Passwort) - **Menschenlesbar** - Klartextformat (JSON) wenn unverschlüsselt
- ⚠️ **Sensible Daten?** - Verschlüsselung aktivieren oder externe Tools nutzen (z.B. 7-Zip)
### Empfehlungen ### Empfehlungen
- 🔐 Backup-Dateien in verschlüsseltem Container speichern - 🔐 Backup-Dateien in verschlüsseltem Container speichern
@@ -321,4 +322,4 @@ Schritt-für-Schritt:
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste - [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown - [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown
**Letzte Aktualisierung:** v1.2.1 (2026-01-05) **Letzte Aktualisierung:** v1.8.1 (2026-02-11)

View File

@@ -276,8 +276,9 @@ Step-by-step:
### Data Protection ### Data Protection
-**Locally stored** - No cloud upload without your action -**Locally stored** - No cloud upload without your action
-**No encryption** - Plain text format for readability -**Optional encryption** _(v1.7.0+)_ - Password-protect backup files
- ⚠️ **Sensitive data?** - Encrypt backup file yourself (e.g., 7-Zip with password) - **Human-readable** - Plain JSON format when unencrypted
- ⚠️ **Sensitive data?** - Enable encryption or use external tools (e.g., 7-Zip)
### Recommendations ### Recommendations
- 🔐 Store backup files in encrypted container - 🔐 Store backup files in encrypted container
@@ -317,8 +318,8 @@ Step-by-step:
--- ---
**📚 See also:** **📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup - [QUICKSTART.md](../QUICKSTART.md) - App installation and setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list - [FEATURES.md](FEATURES.md) - Complete feature list
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown - [DESKTOP.md](DESKTOP.md) - Desktop integration with Markdown
**Last update:** v1.2.1 (2026-01-05) **Last update:** v1.8.1 (2026-02-11)

View File

@@ -48,8 +48,6 @@ git push origin fix/my-bug
## 📱 Installation auf Gerät ## 📱 Installation auf Gerät
## 📱 Installation auf Gerät
### Mit ADB (Empfohlen - sauberes Testing) ### Mit ADB (Empfohlen - sauberes Testing)
```bash ```bash
# Gerät verbinden # Gerät verbinden

View File

@@ -541,17 +541,7 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
## 🔮 Roadmap ## 🔮 Roadmap
### v1.1 Siehe [UPCOMING.md](UPCOMING.md) für die vollständige Roadmap und geplante Features.
- [ ] Suche & Filter
- [ ] Dark Mode
- [ ] Tags/Kategorien
- [ ] Markdown Preview
### v2.0
- [ ] Desktop Client (Flutter)
- [ ] End-to-End Verschlüsselung
- [ ] Shared Notes (Collaboration)
- [ ] Attachment Support
--- ---
@@ -564,4 +554,4 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
--- ---
**Letzte Aktualisierung:** 25. Dezember 2025 **Letzte Aktualisierung:** Februar 2026

View File

@@ -541,17 +541,7 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
## 🔮 Roadmap ## 🔮 Roadmap
### v1.1 See [UPCOMING.md](UPCOMING.md) for the full roadmap and planned features.
- [ ] Search & Filter
- [ ] Dark Mode
- [ ] Tags/Categories
- [ ] Markdown Preview
### v2.0
- [ ] Desktop Client (Flutter)
- [ ] End-to-End Encryption
- [ ] Shared Notes (Collaboration)
- [ ] Attachment Support
--- ---
@@ -564,4 +554,4 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
--- ---
**Last updated:** December 25, 2025 **Last updated:** February 2026

View File

@@ -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)_ ## 🌍 Mehrsprachigkeit _(NEU in v1.5.0)_
### Unterstützte Sprachen ### Unterstützte Sprachen
@@ -129,9 +173,12 @@
### Sync-Mechanismus ### Sync-Mechanismus
-**Upload** - Lokale Änderungen zum Server -**Upload** - Lokale Änderungen zum Server
-**Download** - Server-Änderungen in App -**Download** - Server-Änderungen in App
-**Parallele Downloads** _(NEU in v1.8.0)_ - Bis zu 5 gleichzeitige Downloads
-**Konflikt-Erkennung** - Bei gleichzeitigen Änderungen -**Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
-**Konfliktfreies Merging** - Last-Write-Wins via Timestamp -**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 -**Fehlerbehandlung** - Retry bei Netzwerkproblemen
-**Offline-First** - App funktioniert ohne Server -**Offline-First** - App funktioniert ohne Server
@@ -140,6 +187,9 @@
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern -**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication -**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen -**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)_ -**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/` -**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
@@ -196,11 +246,12 @@
## 🛠️ Technische Details ## 🛠️ Technische Details
### Plattform ### Plattform
- **Android 8.0+** (API 26+) - **Android 7.0+** (API 24+)
- **Target SDK 36** (Android 15) - **Target SDK 36** (Android 15)
- **Kotlin** - Moderne Programmiersprache - **Kotlin** - Moderne Programmiersprache
- **Jetpack Compose** - Deklaratives UI-Framework
- **Material Design 3** - Neueste Design-Richtlinien - **Material Design 3** - Neueste Design-Richtlinien
- **ViewBinding** - Typ-sichere View-Referenzen - **Jetpack Glance** _(v1.8.0)_ - Widget-Framework
### Architektur ### Architektur
- **MVVM-Light** - Einfache Architektur - **MVVM-Light** - Einfache Architektur
@@ -218,6 +269,7 @@
- **Gson** - JSON Serialization - **Gson** - JSON Serialization
- **WorkManager** - Background Tasks - **WorkManager** - Background Tasks
- **OkHttp** - HTTP Client (via Sardine) - **OkHttp** - HTTP Client (via Sardine)
- **Glance** _(v1.8.0)_ - Widget-Framework
### Build-Varianten ### Build-Varianten
- **Standard** - Universal APK (100% FOSS, keine Google-Dependencies) - **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
@@ -247,22 +299,12 @@
## 🔮 Zukünftige Features ## 🔮 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 ### v2.0.0 - Legacy Cleanup
- **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen - **Veraltete Activities entfernen** - Durch Compose-Varianten ersetzen
- **Erledigte Items** - Durchstreichen/Abhaken - **LocalBroadcastManager → SharedFlow** - Moderne Event-Architektur
- **Drag & Drop** - Items neu anordnen - **WebDavSyncService aufteilen** - SyncOrchestrator, NoteUploader, NoteDownloader
### 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
--- ---
@@ -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)

View File

@@ -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)_ ## 🌍 Multilingual Support _(NEW in v1.5.0)_
### Supported Languages ### Supported Languages
@@ -129,9 +173,12 @@
### Sync Mechanism ### Sync Mechanism
-**Upload** - Local changes to server -**Upload** - Local changes to server
-**Download** - Server changes to app -**Download** - Server changes to app
-**Parallel downloads** _(NEW in v1.8.0)_ - Up to 5 simultaneous downloads
-**Conflict detection** - On simultaneous changes -**Conflict detection** - On simultaneous changes
-**Conflict-free merging** - Last-Write-Wins via timestamp -**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 -**Error handling** - Retry on network issues
-**Offline-first** - App works without server -**Offline-first** - App works without server
@@ -140,6 +187,9 @@
-**HTTP/HTTPS** - HTTP only local, HTTPS for external -**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication -**Username/password** - Basic authentication
-**Connection test** - Test in settings -**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)_ -**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/` -**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
@@ -196,11 +246,12 @@
## 🛠️ Technical Details ## 🛠️ Technical Details
### Platform ### Platform
- **Android 8.0+** (API 26+) - **Android 7.0+** (API 24+)
- **Target SDK 36** (Android 15) - **Target SDK 36** (Android 15)
- **Kotlin** - Modern programming language - **Kotlin** - Modern programming language
- **Jetpack Compose** - Declarative UI framework
- **Material Design 3** - Latest design guidelines - **Material Design 3** - Latest design guidelines
- **ViewBinding** - Type-safe view references - **Jetpack Glance** _(v1.8.0)_ - Widget framework
### Architecture ### Architecture
- **MVVM-Light** - Simple architecture - **MVVM-Light** - Simple architecture
@@ -218,6 +269,7 @@
- **Gson** - JSON serialization - **Gson** - JSON serialization
- **WorkManager** - Background tasks - **WorkManager** - Background tasks
- **OkHttp** - HTTP client (via Sardine) - **OkHttp** - HTTP client (via Sardine)
- **Glance** _(v1.8.0)_ - Widget framework
### Build Variants ### Build Variants
- **Standard** - Universal APK (100% FOSS, no Google dependencies) - **Standard** - Universal APK (100% FOSS, no Google dependencies)
@@ -247,22 +299,12 @@
## 🔮 Future Features ## 🔮 Future Features
Planned for upcoming versions: Planned for upcoming versions see [UPCOMING.md](UPCOMING.md) for the full roadmap.
### v1.4.0 - Checklists ### v2.0.0 - Legacy Cleanup
- **Checklist notes** - New note type with checkboxes - **Remove deprecated Activities** - Replace with Compose equivalents
- **Completed items** - Strike-through/check off - **LocalBroadcastManager → SharedFlow** - Modern event architecture
- **Drag & drop** - Reorder items - **WebDavSyncService split** - SyncOrchestrator, NoteUploader, NoteDownloader
### 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
--- ---
@@ -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
View 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)

View File

@@ -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` - **Pinterest-artiges Staggered Grid** - Lückenfreies Layout mit dynamischen Vorschauzeilen
- **Intelligente Größen** - Kleine Notizen (kurzer Text, wenige Checklist-Items) kompakt dargestellt - **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht wechseln
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht in Einstellungen wechseln - **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
- **120 FPS optimiert** - Lazy Loading für flüssiges Scrollen bei vielen Notizen
### 🔧 Server-Ordner Prüfung ### 📡 Sync-Verbesserungen
- **WebDAV Folder Check** - Prüft ob der Ordner auf dem Server existiert und beschreibbar ist - **WiFi-Only Sync Toggle** - Nur über WiFi synchronisieren
- **Bessere Fehlermeldungen** - Hilfreiche Hinweise bei Server-Problemen - **VPN-Unterstützung** - Sync funktioniert korrekt über VPN-Tunnels
- **Connection-Test Verbesserung** - Prüft Read/Write Permissions - **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) ## v1.7.1 - Android 9 Fix & VPN ✅
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
> **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 ### 🎨 UI Features
- **Widget** - Schnellzugriff vom Homescreen
- **Kategorien/Tags** - Notizen organisieren - **Kategorien/Tags** - Notizen organisieren
- **Suche** - Volltextsuche in Notizen - **Suche** - Volltextsuche in Notizen

View File

@@ -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` - **Pinterest-style staggered grid** - Gapless layout with dynamic preview lines
- **Smart sizing** - Small notes (short text, few checklist items) displayed compactly - **Layout toggle** - Switch between list and grid in settings
- **Layout toggle** - Switch between List and Grid view in settings - **Adaptive columns** - 2-3 columns based on screen size
- **Adaptive columns** - 2-3 columns based on screen size
- **120 FPS optimized** - Lazy loading for smooth scrolling with many notes
### 🔧 Server Folder Check ### 📡 Sync Improvements
- **WebDAV folder check** - Checks if folder exists and is writable on server - **WiFi-only sync toggle** - Sync only when connected to WiFi
- **Better error messages** - Helpful hints for server problems - **VPN support** - Sync works correctly through VPN tunnels
- **Connection test improvement** - Checks read/write permissions - **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) ## v1.7.1 - Android 9 Fix & VPN ✅
- **Improved progress dialogs** - Material Design 3 compliant
> **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 ### 🎨 UI Features
- **Widget** - Quick access from homescreen
- **Categories/Tags** - Organize notes - **Categories/Tags** - Organize notes
- **Search** - Full-text search in notes - **Search** - Full-text search in notes

View File

@@ -5,34 +5,50 @@ Diese Verzeichnisstruktur enthält alle Metadaten für die F-Droid-Veröffentlic
## Struktur ## Struktur
``` ```
fastlane/metadata/android/de-DE/ fastlane/metadata/android/
├── title.txt # App-Name (max 50 Zeichen) ├── de-DE/ # Deutsche Lokalisierung (primär)
├── short_description.txt # Kurzbeschreibung (max 80 Zeichen) │ ├── title.txt # App-Name (max 50 Zeichen)
├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen) │ ├── short_description.txt # Kurzbeschreibung (max 80 Zeichen)
├── changelogs/ │ ├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen)
── 1.txt # Changelog für Version 1 ── changelogs/
└── images/ │ │ ├── 1.txt ... 21.txt # Changelogs pro versionCode (max 500 Zeichen!)
└── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit) └── images/
── 1.png # Hauptansicht (Notizliste) ── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit)
├── 2.png # Notiz-Editor ├── 1.png ... 5.png
├── 3.png # Settings └── en-US/ # Englische Lokalisierung
└── 4.png # Empty State ├── 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 ## Screenshots erstellen
Verwende einen Android Emulator oder physisches Gerät mit: Verwende ein physisches Gerät oder Emulator mit:
- Material You Theme aktiviert - Material You Theme aktiviert
- Deutsche Sprache - Deutsche/Englische Sprache je nach Locale
- Screenshots in hoher Auflösung (1080x2400 empfohlen) - 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 ## F-Droid Build-Konfiguration
Die App verwendet den `fdroid` Build-Flavor ohne proprietäre Dependencies. 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

View File

@@ -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 - Verbesserte Sync-Performance durch optimierten Code
- Stabilere Fehlerbehandlung bei Verbindungsproblemen - Stabilere Fehlerbehandlung bei Verbindungsproblemen
- Speichereffizientere Datenverarbeitung - Speichereffizientere Datenverarbeitung
- Datenschutz-Hinweis fur Datei-Logging hinzugefugt - Datenschutz-Hinweis für Datei-Logging hinzugefügt

View 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

View File

@@ -1,12 +1,12 @@
v1.2.2 - Rückwärtskompatibilität für v1.2.0 User v1.2.2 - Rückwärtskompatibilität für v1.2.0 User
Kritische Fehlerbehebung Kritische Fehlerbehebung
• Server-Wiederherstellung findet jetzt ALLE Notizen (Root + /notes/) • Server-Restore findet jetzt ALLE Notizen (Root + /notes/)
• User die von v1.2.0 upgraden verlieren keine Daten mehr • Upgrade von v1.2.0 ohne Datenverlust
• Alte Notizen aus Root-Ordner werden beim Restore gefunden • Alte Notizen aus Root-Ordner werden gefunden
Technische Details Technische Details
• Dual-Mode Download nur bei Server-Restore aktiv • 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 • Automatische Deduplication verhindert Duplikate
• Sanfte Migration: Neue Uploads gehen in /notes/, alte bleiben lesbar • Sanfte Migration: Uploads /notes/, alte bleiben lesbar

View File

@@ -1,29 +1,31 @@
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features. Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features.
Hauptfunktionen: Hauptfunktionen:
• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop) • Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop, Sortierung)
NEU: Raster-Ansicht (Grid View) für Notizen • Raster- und Listen-Ansicht mit Notizfarben
• Homescreen-Widgets (Quick-Note, Checkliste mit interaktiven Checkboxen)
• Multi-Device Sync (Handy, Tablet, Desktop) • Multi-Device Sync (Handy, Tablet, Desktop)
• WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.) • WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.)
• Markdown-Export und Import für Desktop-Editoren (Obsidian, VS Code) • 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 • Konfigurierbare Sync-Trigger: onSave, onResume, WiFi, periodisch, Boot
• Komplett offline nutzbar • Komplett offline nutzbar keine Werbung, keine Tracker
• Keine Werbung, keine Tracker
Datenschutz & Sicherheit: Datenschutz & Sicherheit:
• Alle Daten bleiben bei dir keine Cloud, keine Tracking-Bibliotheken • Alle Daten bleiben bei dir keine Cloud, keine Tracking-Bibliotheken
• Unterstützung für selbstsignierte SSL-Zertifikate (Self-signed SSL) • Unterstützung für selbstsignierte SSL-Zertifikate
SHA-256 Hash des Signaturzertifikats in App und Releases sichtbar Verschlüsselte lokale Backups
Synchronisation: Synchronisation:
Automatisch oder manuell, optimierte Performance, periodischer Sync optional Parallele Downloads (bis zu 5 gleichzeitig)
Intelligente Konfliktlösung, Lösch-Tracking, Batch-Aktionen Live Sync-Fortschritt mit Phasen-Anzeige
• Intelligente Konfliktlösung, Server-Löschungs-Erkennung
• Post-Update Changelog-Dialog
UI & Design: UI & Design:
• Moderne Jetpack Compose Oberfläche • Moderne Jetpack Compose Oberfläche
• Material Design 3, Dynamic Colors, Dark Mode • Material Design 3, Dynamic Colors, Dark Mode
Animationen und Live Sync-Status Notiz- und Checklisten-Sortierung (Titel, Datum, Farbe, alphabetisch)
Mehrsprachig: Mehrsprachig:
• Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl • Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View 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

View 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

View 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

View File

@@ -1,29 +1,31 @@
Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features. Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features.
Key Features: Key Features:
• Text notes and checklists (tap-to-check, drag & drop) • Text notes and checklists (tap-to-check, drag & drop, sorting)
NEW: Grid view for notes Grid and list view with note color support
• Homescreen widgets (quick-note, checklist with interactive checkboxes)
• Multi-device sync (phone, tablet, desktop) • Multi-device sync (phone, tablet, desktop)
• WebDAV sync with your own server (Nextcloud, ownCloud, etc.) • WebDAV sync with your own server (Nextcloud, ownCloud, etc.)
• Markdown export/import for desktop editors (Obsidian, VS Code) • 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 • Configurable sync triggers: onSave, onResume, WiFi, periodic, boot
• Fully usable offline • Fully usable offline no ads, no trackers
• No ads, no trackers
Privacy & Security: Privacy & Security:
• Your data stays with you no cloud, no tracking libraries • Your data stays with you no cloud, no tracking libraries
• Support for self-signed SSL certificates • Support for self-signed SSL certificates
SHA-256 hash of signing certificate shown in app and releases Encrypted local backups
Synchronization: Synchronization:
Automatic or manual, optimized performance, optional periodic sync Parallel downloads (up to 5 simultaneous)
Smart conflict resolution, deletion tracking, batch actions Live sync progress with phase indicators
• Smart conflict resolution, server deletion detection
• Post-update changelog dialog
UI & Design: UI & Design:
• Modern Jetpack Compose interface • Modern Jetpack Compose interface
• Material Design 3, dynamic colors, dark mode • Material Design 3, dynamic colors, dark mode
Animations and live sync status Note & checklist sorting (title, date, color, alphabetical)
Multilingual: Multilingual:
• English and German, automatic detection, in-app language selector • English and German, automatic detection, in-app language selector

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 199 KiB