20 Commits

Author SHA1 Message Date
7ddad7e5e7 mid commit 2026-02-18 00:45:02 +00:00
inventory69
5764e8c0ec Release v1.8.1: Checklist Sorting, Sync Improvements & UX Polish 2026-02-11 22:01:22 +01:00
inventory69
b1e7f1750e chore: update screenshots for fdroid metadata in both de-DE and en-US 2026-02-11 21:54:47 +01:00
inventory69
da371436cd fix(editor): IMPL_15 - Add Item respects separator position in checklist
Changes:
- NoteEditorViewModel.kt: Add calculateInsertIndexForNewItem() helper method
- NoteEditorViewModel.kt: Rewrite addChecklistItemAtEnd() to insert before
  first checked item (MANUAL/UNCHECKED_FIRST) instead of appending at list end
- NoteEditorViewModel.kt: Add cross-boundary guard to addChecklistItemAfter()
  preventing new unchecked items from being inserted inside checked section
- ChecklistSortingTest.kt: Add ChecklistSortOption import
- ChecklistSortingTest.kt: Add 10 IMPL_15 unit tests covering all sort modes,
  edge cases (empty list, all checked, no checked), and position stability

Root cause: addChecklistItemAtEnd() appended new unchecked items at the end
of the flat list, after checked items. The UI splits items by count
(subList(0, uncheckedCount)), not by isChecked state — causing checked items
to appear above the separator and new items below it.

Fix: Insert new items at the semantically correct position per sort mode.
MANUAL/UNCHECKED_FIRST: before first checked item (above separator).
All other modes: at list end (no separator visible, no visual issue).

All 19 unit tests pass (9 existing + 10 new). No UI changes required.
2026-02-11 21:34:18 +01:00
inventory69
cf54f44377 docs(v1.8.1): comprehensive metadata & documentation update for F-Droid release
Complete overhaul of all metadata and documentation for v1.8.1:

F-Droid Critical:
- Fix broken umlauts in de-DE/10.txt changelog (für, hinzugefügt, aufgeräumt)
- Shorten de-DE/7.txt changelog for brevity
- Remove trailing newline from en-US/title.txt

Version & Accuracy:
- Update README badges (Android 7.0+) and footer (v1.8.1)
- Add widgets, sorting, parallel sync to highlights
- Fix APK names in workflows (removed "universal")
- Update cache action v3 → v4 in PR workflow
- Fix CONTRIBUTING.md filename references
- Update QUICKSTART guides (APK name, typo, Android version, docs links)

Feature Documentation:
- Update full_description.txt (both locales) - remove NEW/NEU labels, add v1.8.0+ features
- Major FEATURES.md update - add Views & Layout, Widgets sections, updated tech stack
- Add UPCOMING.md v1.7.0-1.8.1 as released sections
- Update DOCS.md roadmap references and dates to Feb 2026
- Create missing en-US changelogs 1.txt, 2.txt

Supplementary Fixes:
- Update BACKUP.md - add encryption docs, fix cross-references
- Add CHANGELOG.md reference links v1.2.1-v1.8.1 (15 missing)
- Fix DEBUG_APK.md duplicate header
- Rewrite fastlane/README.md with both locales and limits table
- Create SELF_SIGNED_SSL.de.md (full German translation)

Affects: 26 files across READMEs, docs/, fastlane/, workflows
2026-02-11 15:45:51 +01:00
inventory69
bc3f137669 docs: add v1.8.1 changelogs and F-Droid release notes
Changes:
- CHANGELOG.md: Add v1.8.1 section (Bug Fixes, New Features, Improvements, Code Quality)
- CHANGELOG.de.md: Add v1.8.1 section (German translation)
- fastlane/en-US/changelogs/21.txt: F-Droid release notes (EN, 496 chars)
- fastlane/de-DE/changelogs/21.txt: F-Droid release notes (DE, 493 chars)

Changelog covers all 13 branch commits:
- 4 bug fixes (sort persistence, widget scroll, auto-sync toast, drag flicker)
- 4 new features (widget sorting, preview sorting, auto-scroll, cross-boundary drag)
- 3 improvements (sync rate-limiting, toast→banner migration, ProGuard audit)
- 1 code quality (detekt compliance, 0 issues)

F-Droid changelogs stay under 500-char limit.
In-app assets are auto-generated by copyChangelogsToAssets build task.
2026-02-11 11:39:21 +01:00
inventory69
1a6617a4ec chore(quality): resolve all detekt findings for v1.8.1 release
Changes:
- UpdateChangelogSheet.kt: Remove 3 unused imports (Intent, Uri, withStyle)
- NoteEditorScreen.kt: Extract DRAGGING_ELEVATION_DP and AUTO_SCROLL_DELAY_MS constants
- NoteEditorScreen.kt: Add @Suppress("LongParameterList") to DraggableChecklistItem
- NoteEditorViewModel.kt: Refactor loadNote() into loadExistingNote(), loadChecklistData(), initNewNote()
- NoteEditorViewModel.kt: Extract parseSortOption() utility for safe enum parsing
- NoteEditorViewModel.kt: Fix NestedBlockDepth and SwallowedException findings
- ChecklistPreviewHelper.kt: Suppress SwallowedException for intentional fallback
- NoteWidgetActions.kt: Suppress SwallowedException for intentional fallback
- NoteWidgetContent.kt: Suppress SwallowedException in ChecklistCompactView + ChecklistFullView
- SyncWorker.kt: Suppress LongMethod on doWork() (linear flow, debug logging)
- detekt.yml: Update maxIssues from 100 to 0, update version comment

All 12 detekt findings resolved. Build compiles clean, 0 lint errors.
2026-02-11 11:22:10 +01:00
inventory69
27e6b9d4ac refactor(ui): IMPL_12 - Migrate Toast notifications to Banner system
Changes:
- SyncProgress.kt: Add INFO phase to SyncPhase enum
- SyncProgress.kt: Update isVisible to always show INFO phase
- SyncStateManager.kt: Add showInfo() and showError() methods
- SyncProgressBanner.kt: Add INFO icon (Icons.Outlined.Info) + secondaryContainer color
- SyncProgressBanner.kt: Add INFO branch in phaseToString()
- ComposeMainActivity.kt: Add auto-hide for INFO phase (2.5s delay)
- MainViewModel.kt: Replace Toast with Banner in deleteNoteFromServer()
- MainViewModel.kt: Replace Toast with Banner in deleteMultipleNotesFromServer()
- NoteEditorViewModel.kt: Import SyncStateManager
- NoteEditorViewModel.kt: Add Banner feedback (INFO/ERROR) in editor deleteNote()
- NoteEditorViewModel.kt: Remove NOTE_SAVED toast (NavigateBack is sufficient)
- NoteEditorViewModel.kt: Remove NOTE_DELETED toast (already done)

All non-interactive notifications now use the unified Banner system.
Server-delete results show as INFO (success) or ERROR (failure) banners.
Snackbars with Undo actions and NOTE_IS_EMPTY validation toasts remain unchanged.
2026-02-11 11:00:52 +01:00
inventory69
3e4b1bd07e feat(editor): IMPL_05 - Auto-scroll on line wrap in checklist editor
Changes:
- ChecklistItemRow.kt: Import mutableIntStateOf
- ChecklistItemRow.kt: Add onHeightChanged callback parameter
- ChecklistItemRow.kt: Track lastLineCount state for line wrap detection
- ChecklistItemRow.kt: Detect line count increase in onTextLayout and trigger callback
- NoteEditorScreen.kt: Add scrollToItemIndex state in ChecklistEditor
- NoteEditorScreen.kt: Add LaunchedEffect for auto-scroll on height change
- NoteEditorScreen.kt: Pass onHeightChanged callback to ChecklistItemRow

When typing in a checklist item at the bottom of the list, the editor now
automatically scrolls to keep the cursor visible when text wraps to a new line.
2026-02-11 10:42:08 +01:00
inventory69
66d98c0cad feat(widget): IMPL_04 - Add sorting & separator to widget checklists
Changes:
- NoteWidgetContent.kt: Import sortChecklistItemsForPreview and ChecklistSortOption
- NoteWidgetContent.kt: Add WidgetCheckedItemsSeparator composable
- NoteWidgetContent.kt: Apply note.checklistSortOption in ChecklistCompactView
- NoteWidgetContent.kt: Apply note.checklistSortOption in ChecklistFullView
- NoteWidgetContent.kt: Integrate separator for MANUAL/UNCHECKED_FIRST modes
- NoteWidgetContent.kt: Change  to ☑️ emoji (IMPL_06)
- NoteWidgetActions.kt: Add auto-sort after toggle in ToggleChecklistItemAction

Widgets now match editor behavior: apply saved sort option, show separator
between unchecked/checked items, and auto-sort when toggling checkboxes.
2026-02-11 10:32:38 +01:00
inventory69
c72b3fe1c0 fix(widget): IMPL_09 - Fix scroll bug in standard widget size (3x2)
Changes:
- WidgetSizeClass.kt: Add NARROW_SCROLL and WIDE_SCROLL enum values
- NoteWidget.kt: Add SIZE_NARROW_SCROLL (110x150dp) and SIZE_WIDE_SCROLL (250x150dp) breakpoints
- NoteWidget.kt: Update sizeMode to include new breakpoints
- NoteWidgetContent.kt: Add WIDGET_HEIGHT_SCROLL_THRESHOLD (150dp)
- NoteWidgetContent.kt: Update toSizeClass() to classify 150dp+ height as SCROLL sizes
- NoteWidgetContent.kt: Combine NARROW_SCROLL + NARROW_TALL into single when branch
- NoteWidgetContent.kt: Combine WIDE_SCROLL + WIDE_TALL into single when branch
- NoteWidgetContent.kt: Remove clickable modifier from unlocked checklists to enable scrolling
- Locked checklists retain clickable for options, unlocked checklists scroll freely
2026-02-11 10:20:48 +01:00
inventory69
a1a574a725 fix(sync): IMPL_08B - Bypass global cooldown for onSave syncs
Changes:
- Constants.kt: Add SYNC_ONSAVE_TAG constant for worker tagging
- NoteEditorViewModel.triggerOnSaveSync(): Tag SyncWorker with "onsave" tag
- SyncWorker.doWork(): Skip global cooldown check for onSave-tagged workers
- onSave retains 3 protection layers: 5s own throttle, tryStartSync mutex, syncMutex
- Auto/WiFi/periodic syncs still respect 30s global cooldown
2026-02-11 10:15:33 +01:00
inventory69
7dbc06d102 fix(editor): IMPL_03-FIX - Fix sort option not applied when reopening checklist
Changes:
- NoteEditorViewModel.sortChecklistItems(): Use _lastChecklistSortOption.value instead of always sorting unchecked-first
- Supports all sort modes: MANUAL, UNCHECKED_FIRST, CHECKED_FIRST, ALPHABETICAL_ASC/DESC
- reloadFromStorage() also benefits from fix (uses same sortChecklistItems method)
- Root cause: loadNote() correctly restored _lastChecklistSortOption but sortChecklistItems() ignored it
2026-02-11 09:35:25 +01:00
inventory69
2c43b47e96 feat(preview): IMPL_03 - Add checklist sorting in main preview
Changes:
- Note.kt: Add checklistSortOption field (String?, nullable for backward compatibility)
- Note.fromJson(): Parse checklistSortOption from JSON
- Note.fromMarkdown(): Parse sort field from YAML frontmatter
- Note.toMarkdown(): Include sort field in YAML frontmatter for checklists
- ChecklistPreviewHelper.kt: New file with sortChecklistItemsForPreview() and generateChecklistPreview()
- NoteEditorViewModel.loadNote(): Load and restore checklistSortOption from note
- NoteEditorViewModel.saveNote(): Persist checklistSortOption when saving (both new and existing notes)
- NoteCardCompact.kt: Use generateChecklistPreview() for sorted preview (includes IMPL_06 emoji change)
- NoteCardGrid.kt: Use generateChecklistPreview() for sorted preview (includes IMPL_06 emoji change)
2026-02-11 09:30:18 +01:00
inventory69
ffe0e46e3d feat(sync): IMPL_08 - Add global sync rate-limiting & battery protection
Changes:
- Constants.kt: Add KEY_LAST_GLOBAL_SYNC_TIME and MIN_GLOBAL_SYNC_INTERVAL_MS (30s)
- SyncStateManager.kt: Add canSyncGlobally() and markGlobalSyncStarted() methods
- MainViewModel.triggerAutoSync(): Check global cooldown before individual throttle
- MainViewModel.triggerAutoSync(): Mark global sync start before viewModelScope.launch
- MainViewModel.triggerManualSync(): Mark global sync start after tryStartSync()
- SyncWorker.doWork(): Add SyncStateManager.tryStartSync() coordination with silent=true
- SyncWorker.doWork(): Check global cooldown before expensive server checks
- SyncWorker.doWork(): Call markCompleted()/markError() to update SyncStateManager
- WifiSyncReceiver: Add global cooldown check and KEY_SYNC_TRIGGER_WIFI_CONNECT validation
2026-02-11 09:13:06 +01:00
inventory69
fe6935a6b7 fix(sync): IMPL_11 - Remove unexpected toast on auto-sync
- Remove toast emission in triggerAutoSync() (L697)
- Silent sync now fully respects silent=true flag
- Notes list still updates via loadNotes(), no UX loss
- Toast was bypassing SyncStateManager's silent handling

Changes:
- android/app/.../ui/main/MainViewModel.kt: Remove _showToast.emit() on auto-sync success
2026-02-11 09:06:30 +01:00
inventory69
7b558113cf feat(editor): IMPL_14 - Separator drag cross-boundary with auto-toggle
Separator is now its own LazyColumn item instead of being rendered
inline inside the first checked item's composable. This fixes:

Bug A: Separator disappearing during drag (was hidden as workaround
for height inflation). Now always visible with primary color highlight.

Bug B: Cross-boundary move blocked (isChecked != toItem.isChecked
returned early). Now auto-toggles isChecked when crossing boundary
— like Google Tasks.

Bug C: Drag flicker at separator boundary (draggingItemIndex updated
even when onMove was a no-op → oscillation). Index remapping via
visualToDataIndex()/dataToVisualIndex() ensures correct data indices.

Architecture changes:
- DragDropListState: separatorVisualIndex, index remapping functions,
  isAdjacentSkippingSeparator() skips separator in swap detection
- NoteEditorScreen: Extracted DraggableChecklistItem composable,
  3 LazyColumn blocks (unchecked items, separator, checked items),
  removed hardcoded AnimatedVisibility(visible=true) wrapper
- NoteEditorViewModel: moveChecklistItem() allows cross-boundary
  moves with automatic isChecked toggle
- CheckedItemsSeparator: isDragActive parameter for visual feedback

Files changed:
- DragDropListState.kt (+56 lines)
- NoteEditorScreen.kt (refactored, net +84 lines)
- NoteEditorViewModel.kt (simplified cross-boundary logic)
- CheckedItemsSeparator.kt (drag-awareness parameter)
2026-02-11 08:56:48 +01:00
inventory69
24fe32a973 fix(editor): IMPL_13 - Fix gradient regression & drag-flicker
Bug A1: Gradient never showed because maxLines capped lineCount,
making lineCount > maxLines impossible. Fixed by always setting
maxLines = Int.MAX_VALUE and using hasVisualOverflow + heightIn(max)
for clipped overflow detection.

Bug A2: derivedStateOf captured stale val from Detekt refactor
(commit 1da1a63). Replaced with direct computation.

Bug B1: animateItem() on dragged item conflicted with manual offset
(from IMPL_017 commit 900dad7). Fixed with conditional Modifier:
if (!isDragging) Modifier.animateItem() else Modifier.

Bug B2: Item size could change during drag. Added size snapshot
in DragDropListState.onDragStart for stable endOffset calculation.

Files changed:
- ChecklistItemRow.kt: hasVisualOverflow, direct lineCount check,
  maxLines=Int.MAX_VALUE always, heightIn(max=collapsedHeightDp)
- NoteEditorScreen.kt: conditional animateItem on isDragging
- DragDropListState.kt: draggingItemSize snapshot for stable drag
2026-02-11 08:49:13 +01:00
inventory69
b5a3a3c096 Merge branch 'main' into release/v1.8.1 2026-02-11 08:39:21 +01:00
inventory69
63561737e1 feat(proguard): IMPL_07 - Add missing Widget & Compose ProGuard rules
- Add Widget ActionCallback rules to prevent R8 removal (fixes potential widget crashes)
- Add Glance state preservation rules for widget preferences
- Add Compose TextLayout rules to prevent gradient regression in release builds
- Bump version to 1.8.1 (versionCode 21, versionName "1.8.1")

Fixes:
- Widget actions (ToggleChecklistItemAction, etc.) could be stripped by R8
- Glance widget state (DataStore-based) needs reflection protection
- Compose onTextLayout callbacks could be optimized away causing gradient issues

Files changed:
- android/app/proguard-rules.pro: Added v1.8.1 rules section (+17 lines)
- android/app/build.gradle.kts: Version bump 1.8.0 → 1.8.1
2026-02-10 23:10:43 +01:00
80 changed files with 2466 additions and 941 deletions

View File

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

View File

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

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
### 🚨 CRITICAL BUGFIX (Tag neu erstellt)
@@ -891,6 +968,21 @@ Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist j
---
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1

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
### 🚨 CRITICAL BUGFIX (Tag recreated)
@@ -890,6 +967,21 @@ The complete UI has been migrated from XML Views to Jetpack Compose. The app is
---
[1.8.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.1
[1.8.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.8.0
[1.7.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.2
[1.7.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.1
[1.7.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.7.0
[1.6.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.1
[1.6.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.6.0
[1.5.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.5.0
[1.4.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.1
[1.4.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.4.0
[1.3.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.2
[1.3.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.1
[1.3.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.3.0
[1.2.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.2
[1.2.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.1
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1

View File

@@ -94,10 +94,10 @@ Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes
Dokumentations-Verbesserungen sind auch Contributions!
**Dateien:**
- `README.md` / `README.en.md` - Übersicht
- `QUICKSTART.md` / `QUICKSTART.en.md` - Schritt-für-Schritt Anleitung
- `DOCS.md` / `DOCS.en.md` - Technische Details
- `server/README.md` / `server/README.en.md` - Server Setup
- `README.de.md` / `README.md` - Übersicht
- `QUICKSTART.de.md` / `QUICKSTART.md` - Schritt-für-Schritt Anleitung
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technische Details
- `server/README.de.md` / `server/README.md` - Server Setup
**Bitte:** Halte beide Sprachen (DE/EN) synchron!
@@ -219,10 +219,10 @@ Use the [Feature Request Template](https://github.com/inventory69/simple-notes-s
Documentation improvements are also contributions!
**Files:**
- `README.md` / `README.en.md` - Overview
- `QUICKSTART.md` / `QUICKSTART.en.md` - Step-by-step guide
- `DOCS.md` / `DOCS.en.md` - Technical details
- `server/README.md` / `server/README.en.md` - Server setup
- `README.de.md` / `README.md` - Overview
- `QUICKSTART.de.md` / `QUICKSTART.md` - Step-by-step guide
- `docs/DOCS.de.md` / `docs/DOCS.md` - Technical details
- `server/README.de.md` / `server/README.md` - Server setup
**Please:** Keep both languages (DE/EN) in sync!
@@ -260,4 +260,4 @@ By contributing, you agree that your code will be published under the [MIT Licen
Öffne ein [Issue](https://github.com/inventory69/simple-notes-sync/issues) oder nutze die [Question Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose).
**Frohe Weihnachten & Happy Coding! 🎄**
**Happy Coding! 🚀**

View File

@@ -8,7 +8,7 @@
## Voraussetzungen
- ✅ Android 8.0+ Smartphone/Tablet
- ✅ Android 7.0+ Smartphone/Tablet
- ✅ WLAN-Verbindung
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
@@ -52,7 +52,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
### Schritt 2: App installieren
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
- Wähle: `simple-notes-sync-vX.X.X-standard.apk`
2. **Installation erlauben:**
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
@@ -261,7 +261,7 @@ Für zuverlässigen Auto-Sync:
## 🆘 Weitere Hilfe
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
- **Vollständige Docs:** [DOCS.md](DOCS.md)
- **Vollständige Docs:** [DOCS.md](docs/DOCS.md)
- **Server Setup Details:** [server/README.md](server/README.md)
---

View File

@@ -8,7 +8,7 @@
## Prerequisites
- ✅ Android 8.0+ smartphone/tablet
- ✅ Android 7.0+ smartphone/tablet
- ✅ WiFi connection
- ✅ Own server with Docker (optional - for self-hosting)
@@ -52,7 +52,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
### Step 2: Install App
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
- Choose: `simple-notes-sync-vX.X.X-standard.apk`
2. **Allow installation:**
- Android: Settings → Security → Enable "Unknown sources" for your browser
@@ -77,7 +77,7 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
4. **Press "Test connection"****
4. **Press "Test connection"**
- ✅ Success? → Continue to step 4
- ❌ Error? → See [Troubleshooting](#troubleshooting)
@@ -261,8 +261,8 @@ For reliable auto-sync:
## 🆘 Further Help
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
- **Server setup details:** [server/README.en.md](server/README.en.md)
- **Complete docs:** [DOCS.md](docs/DOCS.md)
- **Server setup details:** [server/README.md](server/README.md)
---

View File

@@ -8,7 +8,7 @@
<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/)
[![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/)
@@ -66,12 +66,14 @@ alt="Get it on F-Droid" align="center" height="80" /></a>
- 📝 **Offline-first** Funktioniert ohne Internet
- 📊 **Flexible Ansichten** Listen- und Grid-Layout
-**Checklisten** Tap-to-Check, Drag & Drop
- 🌍 **Mehrsprachig** Deutsch/Englisch mit Sprachauswahl
- 🔄 **Konfigurierbare Sync-Trigger** onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
- 📌 **Widgets** Home-Screen Quick-Note und Notizlisten-Widget
- 🔀 **Smartes Sortieren** Nach Titel, Änderungsdatum, Erstelldatum, Farbe
-**Paralleler Sync** Lädt bis zu 5 Notizen gleichzeitig herunter
- 🌍 **Mehrsprachig** Deutsch/Englisch mit Sprachauswahl
- 🔒 **Self-hosted** Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** Export/Import als JSON-Datei (optional verschlüsselt)
- 🖥️ **Desktop-Integration** Markdown-Export für Obsidian, VS Code, Typora
- 🔋 **Akkuschonend** ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
- 🎨 **Material Design 3** Dynamischer Dark/Light Mode & Farben
➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
@@ -138,6 +140,6 @@ MIT License siehe [LICENSE](LICENSE)
<div align="center">
<br /><br />
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -8,7 +8,7 @@
<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/)
[![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/)
@@ -66,12 +66,14 @@ alt="Get it on F-Droid" align="center" height="80" /></a>
- 📝 **Offline-first** - Works without internet
- 📊 **Flexible views** - Switch between list and grid layout
-**Checklists** - Tap-to-check, drag & drop
- 🌍 **Multilingual** - English/German with language selector
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
- 📌 **Widgets** - Home screen quick-note and note list widgets
- 🔀 **Smart sorting** - By title, date modified, date created, color
-**Parallel sync** - Downloads up to 5 notes simultaneously
- 🌍 **Multilingual** - English/German with language selector
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
@@ -148,6 +150,6 @@ MIT License - see [LICENSE](LICENSE)
<div align="center">
<br /><br />
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
**v1.8.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
alias(libs.plugins.detekt)
alias(libs.plugins.ksp)
}
import java.util.Properties
@@ -20,18 +21,18 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
versionCode = 20 // 🎉 v1.8.0: Widgets, Sorting, UI Polish, Post-Update Changelog
versionName = "1.8.0" // 🎉 v1.8.0: Major Feature Release
versionCode = 21 // 🐛 v1.8.1: Checklist Fixes, Widget Sorting, ProGuard Audit
versionName = "1.8.1" // 🐛 v1.8.1: Bugfix & Polish Release
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
dependenciesInfo {
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
includeInBundle = false // Also disable for AAB (Google Play)
}
// Product Flavors for F-Droid and standard builds
// Note: APK splits are disabled to ensure single APK output
flavorDimensions += "distribution"
@@ -42,7 +43,7 @@ android {
// All dependencies in this project are already FOSS-compatible
// No APK splits - F-Droid expects single universal APK
}
create("standard") {
dimension = "distribution"
// Standard builds can include Play Services in the future if needed
@@ -57,7 +58,7 @@ android {
if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
@@ -72,11 +73,11 @@ android {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isDebuggable = true
// Optionales separates Icon-Label für Debug-Builds
resValue("string", "app_name_debug", "Simple Notes (Debug)")
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -98,12 +99,12 @@ android {
buildConfig = true // Enable BuildConfig generation
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
}
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
testOptions {
unitTests.isReturnDefaultValues = true
}
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
// composeCompiler { }
@@ -162,6 +163,15 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Homescreen Widgets
// ═══════════════════════════════════════════════════════════════════════
@@ -180,7 +190,7 @@ ktlint {
outputToConsole = true
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
enableExperimentalRules = false
filter {
exclude("**/generated/**")
exclude("**/build/**")
@@ -196,7 +206,7 @@ detekt {
allRules = false
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
// Parallel-Verarbeitung für schnellere Checks
parallel = true
}
@@ -205,13 +215,13 @@ detekt {
// Single source of truth: F-Droid changelogs are reused in the app
tasks.register<Copy>("copyChangelogsToAssets") {
description = "Copies F-Droid changelogs to app assets for post-update dialog"
from("$rootDir/../fastlane/metadata/android") {
include("*/changelogs/*.txt")
}
into("$projectDir/src/main/assets/changelogs")
// Preserve directory structure: en-US/20.txt, de-DE/20.txt
eachFile {
val parts = relativePath.segments
@@ -222,11 +232,11 @@ tasks.register<Copy>("copyChangelogsToAssets") {
relativePath = RelativePath(true, parts[0], parts[2])
}
}
includeEmptyDirs = false
}
// Run before preBuild to ensure changelogs are available
tasks.named("preBuild") {
dependsOn("copyChangelogsToAssets")
}
}

View File

@@ -77,3 +77,23 @@
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
# This class only exists on API 35+ but Compose handles the fallback gracefully
-dontwarn android.text.Layout$TextInclusionStrategy
# ═══════════════════════════════════════════════════════════════════════
# v1.8.1: Widget & Compose Fixes
# ═══════════════════════════════════════════════════════════════════════
# Glance Widget ActionCallbacks (instanziiert via Reflection durch actionRunCallback<T>())
# Ohne diese Rule findet R8 die Klassen nicht zur Laufzeit Widget-Crash
-keep class dev.dettmer.simplenotes.widget.*Action { *; }
-keep class dev.dettmer.simplenotes.widget.*Receiver { *; }
# Glance Widget State (Preferences-basiert, intern via Reflection)
-keep class androidx.glance.appwidget.state.** { *; }
-keep class androidx.datastore.preferences.** { *; }
# Compose Text Layout: Verhindert dass R8 onTextLayout-Callbacks
# als Side-Effect-Free optimiert (behebt Gradient-Regression)
-keepclassmembers class androidx.compose.foundation.text.** {
<methods>;
}
-keep class androidx.compose.ui.text.TextLayoutResult { *; }

View File

@@ -36,8 +36,6 @@ import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
@@ -56,30 +54,30 @@ import dev.dettmer.simplenotes.models.NoteType
*/
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView
private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 🔄 v1.3.1: Sync Status Banner
private lateinit var syncStatusBanner: LinearLayout
private lateinit var syncStatusText: TextView
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
// Menu reference for sync button state
private var optionsMenu: Menu? = null
// Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>()
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
@@ -89,7 +87,7 @@ class MainActivity : AppCompatActivity() {
private const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L
}
/**
* BroadcastReceiver für Background-Sync Completion (Periodic Sync)
*/
@@ -97,9 +95,9 @@ class MainActivity : AppCompatActivity() {
override fun onReceive(context: Context?, intent: Intent?) {
val success = intent?.getBooleanExtra("success", false) ?: false
val count = intent?.getIntExtra("count", 0) ?: 0
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
// UI refresh
if (success && count > 0) {
loadNotes()
@@ -107,49 +105,49 @@ class MainActivity : AppCompatActivity() {
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
// Install Splash Screen (Android 12+)
installSplashScreen()
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_main)
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
Logger.init(this)
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
}
// Alte Sync-Notifications beim App-Start löschen
NotificationHelper.clearSyncNotifications(this)
// Permission für Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission()
}
// 🌍 v1.7.2: Debug Locale für Fehlersuche
logLocaleInfo()
findViews()
setupToolbar()
setupRecyclerView()
setupFab()
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates
setupSyncStateObserver()
}
/**
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
*/
@@ -200,7 +198,7 @@ class MainActivity : AppCompatActivity() {
}
}
}
/**
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
*/
@@ -210,32 +208,32 @@ class MainActivity : AppCompatActivity() {
// SwipeRefresh
swipeRefreshLayout.isEnabled = enabled
}
override fun onResume() {
super.onResume()
Logger.d(TAG, "📱 MainActivity.onResume() - Registering receivers")
// Register BroadcastReceiver für Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
)
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes (scroll to top wird in loadNotes() gemacht)
loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
triggerAutoSync("onResume")
}
/**
* Automatischer Sync (onResume)
* - Nutzt WiFi-gebundenen Socket (VPN Fix!)
* - Nur Success-Toast (kein "Auto-Sync..." Toast)
*
*
* NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
*/
@@ -244,65 +242,65 @@ class MainActivity : AppCompatActivity() {
if (!canTriggerAutoSync()) {
return
}
// 🔄 v1.3.1: Check if sync already running
// v1.5.0: silent=true - kein Banner bei Auto-Sync
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
lifecycleScope.launch {
try {
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset()
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
// Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
loadNotes()
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted()
} else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund
}
} catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
@@ -310,7 +308,7 @@ class MainActivity : AppCompatActivity() {
}
}
}
/**
* Prüft ob Auto-Sync getriggert werden darf (Throttling)
*/
@@ -318,96 +316,96 @@ class MainActivity : AppCompatActivity() {
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSyncTime
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
return false
}
return true
}
override fun onPause() {
super.onPause()
// Unregister BroadcastReceiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText)
}
private fun setupToolbar() {
setSupportActionBar(toolbar)
}
private fun setupRecyclerView() {
adapter = NotesAdapter { note ->
openNoteEditor(note.id)
}
recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh()
// Setup Swipe-to-Delete
setupSwipeToDelete()
}
/**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/
private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener
}
lifecycleScope.launch {
try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert")
SyncStateManager.reset()
return@launch
}
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Perform sync
val result = syncService.syncNotes()
if (result.isSuccess) {
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes()
@@ -420,13 +418,13 @@ class MainActivity : AppCompatActivity() {
}
}
}
// Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50
)
}
private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag
@@ -437,45 +435,45 @@ class MainActivity : AppCompatActivity() {
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.bindingAdapterPosition
val swipedNote = adapter.currentList[position]
// Store original list BEFORE removing note
val originalList = adapter.currentList.toList()
// Remove from list for visual feedback (NOT from storage yet!)
val listWithoutNote = originalList.toMutableList().apply {
removeAt(position)
}
adapter.submitList(listWithoutNote)
// Show dialog with ability to restore
showServerDeletionDialog(swipedNote, originalList)
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
// Require 80% swipe to trigger
return 0.8f
}
})
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
}
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
if (alwaysDeleteFromServer) {
// Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true)
return
}
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.legacy_delete_dialog_title))
.setMessage(getString(R.string.legacy_delete_dialog_message, note.title))
@@ -504,24 +502,24 @@ class MainActivity : AppCompatActivity() {
.setCancelable(true)
.show()
}
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Delete from storage
storage.deleteNote(note.id)
// Reload to reflect changes
loadNotes()
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
getString(R.string.legacy_delete_with_server, note.title)
} else {
getString(R.string.legacy_delete_local_only, note.title)
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction(getString(R.string.snackbar_undo)) {
// UNDO: Restore note
@@ -534,7 +532,7 @@ class MainActivity : AppCompatActivity() {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO
pendingDeletions.remove(note.id)
// Delete from server if requested
if (deleteFromServer) {
lifecycleScope.launch {
@@ -573,7 +571,7 @@ class MainActivity : AppCompatActivity() {
}
}).show()
}
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
@@ -582,14 +580,14 @@ class MainActivity : AppCompatActivity() {
showNoteTypePopup(view)
}
}
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
@@ -606,29 +604,29 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
}
private fun loadNotes() {
val notes = storage.loadAllNotes()
// Filter out notes that are pending deletion (prevent flicker)
val filteredNotes = notes.filter { it.id !in pendingDeletions }
// Submit list with callback to scroll to top after list is updated
adapter.submitList(filteredNotes) {
// Scroll to top after list update is complete
@@ -637,7 +635,7 @@ class MainActivity : AppCompatActivity() {
recyclerViewNotes.scrollToPosition(0)
}
}
// Material 3 Empty State Card
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
android.view.View.VISIBLE
@@ -645,7 +643,7 @@ class MainActivity : AppCompatActivity() {
android.view.View.GONE
}
}
private fun openNoteEditor(noteId: String?) {
val intent = Intent(this, NoteEditorActivity::class.java)
noteId?.let {
@@ -653,25 +651,25 @@ class MainActivity : AppCompatActivity() {
}
startActivity(intent)
}
private fun openSettings() {
// v1.5.0: Use new Jetpack Compose Settings
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS)
}
private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) {
return
}
lifecycleScope.launch {
try {
// Create sync service
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
@@ -679,23 +677,23 @@ class MainActivity : AppCompatActivity() {
SyncStateManager.markCompleted(message)
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
// Show result
if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
@@ -703,19 +701,19 @@ class MainActivity : AppCompatActivity() {
} else {
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
SyncStateManager.markError(e.message)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_settings -> {
@@ -729,10 +727,10 @@ class MainActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
@@ -741,50 +739,50 @@ class MainActivity : AppCompatActivity() {
}
}
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
// Restore was successful, reload notes
loadNotes()
}
}
/**
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
*
*
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
*
*
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
*
*
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
*/
private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done"
// Nur einmal ausführen
if (prefs.getBoolean(migrationKey, false)) {
return
}
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
// generiert und hochgeladen wird
val updatedNote = note.copy(
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
@@ -792,24 +790,24 @@ class MainActivity : AppCompatActivity() {
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Migration als erledigt markieren
prefs.edit().putBoolean(migrationKey, true).apply()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showToast(getString(R.string.toast_notifications_enabled))
} else {
@@ -818,39 +816,39 @@ class MainActivity : AppCompatActivity() {
}
}
}
/**
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
*/
private fun logLocaleInfo() {
if (!BuildConfig.DEBUG) return
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
// System Locale
val systemLocale = java.util.Locale.getDefault()
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
// Resources Locale
val resourcesLocale = resources.configuration.locales[0]
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
// Context Locale (API 24+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val contextLocales = resources.configuration.locales
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
}
// Test String Loading
val testString = getString(R.string.toast_already_synced)
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
Logger.d(TAG, "║ Result: '$testString'")
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
}
}

View File

@@ -6,18 +6,22 @@ import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class SimpleNotesApplication : Application() {
companion object {
private const val TAG = "SimpleNotesApp"
}
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
/**
* 🌍 v1.7.1: Apply app locale to Application Context
*
*
* This ensures ViewModels and other components using Application Context
* get the correct locale-specific strings.
*/
@@ -26,71 +30,77 @@ class SimpleNotesApplication : Application() {
// This is handled by AppCompatDelegate which reads from system storage
super.attachBaseContext(base)
}
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger() // Log Koin events
androidContext(this@SimpleNotesApplication) // Provide context to modules
modules(appModule)
}
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
// appear as offline even though they have a configured server
migrateOfflineModeSetting(prefs)
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this)
Logger.d(TAG, "📝 File logging enabled at Application startup")
}
Logger.d(TAG, "🚀 Application onCreate()")
// Initialize notification channel
NotificationHelper.createNotificationChannel(this)
Logger.d(TAG, "✅ Notification channel created")
// Initialize NetworkMonitor (WorkManager-based)
// VORTEIL: WorkManager läuft auch ohne aktive App!
networkMonitor = NetworkMonitor(applicationContext)
// Start WorkManager periodic sync
// Dies läuft im Hintergrund auch wenn App geschlossen ist
networkMonitor.startMonitoring()
Logger.d(TAG, "✅ WorkManager-based auto-sync initialized")
}
override fun onTerminate() {
super.onTerminate()
Logger.d(TAG, "🛑 Application onTerminate()")
// WorkManager läuft weiter auch nach onTerminate!
// Nur bei deaktiviertem Auto-Sync stoppen wir es
}
/**
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
*
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
* and NoteEditorViewModel use `true` as default, causing existing users
*
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
* and NoteEditorViewModel use `true` as default, causing existing users
* with configured servers to appear in offline mode after update.
*
*
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
* a server was already configured.
*/
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
// If server was configured → offlineMode = false (continue syncing)
// If no server → offlineMode = true (new users / offline users)
val offlineModeValue = !hasServerConfig
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
}
}

View File

@@ -0,0 +1,33 @@
package dev.dettmer.simplenotes.di
import android.content.Context
import androidx.room.Room
import dev.dettmer.simplenotes.storage.AppDatabase
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.ui.main.MainViewModel
import dev.dettmer.simplenotes.utils.Constants
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
single {
Room.databaseBuilder(
androidContext(),
AppDatabase::class.java,
"notes_database"
).build()
}
single { get<AppDatabase>().noteDao() }
single { get<AppDatabase>().deletedNoteDao() }
// Provide SharedPreferences
single {
androidContext().getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
single { NotesStorage(androidContext(), get()) }
viewModel { MainViewModel(get(), get()) }
}

View File

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

View File

@@ -0,0 +1,14 @@
package dev.dettmer.simplenotes.storage
import androidx.room.Database
import androidx.room.RoomDatabase
import dev.dettmer.simplenotes.storage.dao.DeletedNoteDao
import dev.dettmer.simplenotes.storage.dao.NoteDao
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Database(entities = [NoteEntity::class, DeletedNoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
abstract fun deletedNoteDao(): DeletedNoteDao
}

View File

@@ -1,77 +1,59 @@
package dev.dettmer.simplenotes.storage
import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
import dev.dettmer.simplenotes.storage.entity.NoteEntity
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
class NotesStorage(private val context: Context) {
class NotesStorage(
private val context: Context,
database: AppDatabase
) {
companion object {
private const val TAG = "NotesStorage"
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
private val deletionTrackerMutex = Mutex()
}
private val notesDir: File = File(context.filesDir, "notes").apply {
if (!exists()) mkdirs()
private val noteDao = database.noteDao()
private val deletedNoteDao = database.deletedNoteDao()
suspend fun saveNote(note: NoteEntity) {
noteDao.saveNote(note)
}
fun saveNote(note: Note) {
val file = File(notesDir, "${note.id}.json")
file.writeText(note.toJson())
suspend fun loadNote(id: String): NoteEntity? {
return noteDao.getNote(id)
}
fun loadNote(id: String): Note? {
val file = File(notesDir, "$id.json")
return if (file.exists()) {
Note.fromJson(file.readText())
} else {
null
}
suspend fun loadAllNotes(): List<NoteEntity> {
return noteDao.getAllNotes()
}
/**
* Lädt alle Notizen aus dem lokalen Speicher.
*
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
* damit der User die Sortierung konfigurieren kann.
*/
fun loadAllNotes(): List<Note> {
return notesDir.listFiles()
?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) }
?: emptyList()
}
fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json")
val deleted = file.delete()
if (deleted) {
suspend fun deleteNote(id: String): Boolean {
val deletedRows = noteDao.deleteNoteById(id)
if (deletedRows > 0) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId)
trackDeletionSafe(id, deviceId)
return true
}
return deleted
return false
}
fun deleteAllNotes(): Boolean {
suspend fun deleteAllNotes(): Boolean {
return try {
val notes = loadAllNotes()
val deviceId = DeviceIdGenerator.getDeviceId(context)
for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically
// Batch tracking and deleting
notes.forEach { note ->
trackDeletionSafe(note.id, deviceId)
}
noteDao.deleteAllNotes()
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true
} catch (e: Exception) {
@@ -79,104 +61,31 @@ class NotesStorage(private val context: Context) {
false
}
}
// === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
/**
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
*
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
* auf den Deletion Tracker.
*
* @param noteId ID der gelöschten Notiz
* @param deviceId Geräte-ID für Konflikt-Erkennung
*/
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
deletionTrackerMutex.withLock {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
}
}
/**
* Legacy-Methode ohne Mutex-Schutz.
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
*
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
*/
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
// Room handles internal transactions and thread-safety natively.
// The Mutex is no longer required.
deletedNoteDao.trackDeletion(DeletedNoteEntity(noteId, deviceId))
Logger.d(TAG, "📝 Tracked deletion: $noteId")
}
fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker()
return tracker.isDeleted(noteId)
suspend fun isNoteDeleted(noteId: String): Boolean {
return deletedNoteDao.isNoteDeleted(noteId)
}
fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker())
suspend fun clearDeletionTracker() {
deletedNoteDao.clearTracker()
Logger.d(TAG, "🗑️ Deletion tracker cleared")
}
/**
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
* This ensures notes are uploaded to the new server on next sync
*/
fun resetAllSyncStatusToPending(): Int {
val notes = loadAllNotes()
var updatedCount = 0
notes.forEach { note ->
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
saveNote(updatedNote)
updatedCount++
}
}
suspend fun resetAllSyncStatusToPending(): Int {
val updatedCount = noteDao.updateSyncStatus(
oldStatus = SyncStatus.SYNCED,
newStatus = SyncStatus.PENDING
)
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
fun getNotesDir(): File = notesDir
}

View File

@@ -0,0 +1,19 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.storage.entity.DeletedNoteEntity
@Dao
interface DeletedNoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun trackDeletion(deletedNote: DeletedNoteEntity)
@Query("SELECT EXISTS(SELECT 1 FROM deleted_notes WHERE noteId = :noteId)")
suspend fun isNoteDeleted(noteId: String): Boolean
@Query("DELETE FROM deleted_notes")
suspend fun clearTracker()
}

View File

@@ -0,0 +1,29 @@
package dev.dettmer.simplenotes.storage.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.entity.NoteEntity
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveNote(note: NoteEntity)
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getNote(id: String): NoteEntity?
@Query("SELECT * FROM notes")
suspend fun getAllNotes(): List<NoteEntity>
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteNoteById(id: String): Int
@Query("DELETE FROM notes")
suspend fun deleteAllNotes(): Int
@Query("UPDATE notes SET syncStatus = :newStatus WHERE syncStatus = :oldStatus")
suspend fun updateSyncStatus(oldStatus: SyncStatus, newStatus: SyncStatus): Int
}

View File

@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "deleted_notes")
data class DeletedNoteEntity(
@PrimaryKey val noteId: String,
val deviceId: String,
val deletedAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,13 @@
package dev.dettmer.simplenotes.storage.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.dettmer.simplenotes.models.SyncStatus
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val content: String,
val timestamp: Long,
val syncStatus: SyncStatus
)

View File

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

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) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
@@ -104,7 +105,42 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)")
Logger.d(TAG, "📍 Step 2: SyncStateManager coordination & global cooldown (v1.8.1)")
}
// 🆕 v1.8.1 (IMPL_08): SyncStateManager-Koordination
// Verhindert dass Foreground und Background gleichzeitig syncing-State haben
val prefs = applicationContext.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🆕 v1.8.1 (IMPL_08B): onSave-Syncs bypassen den globalen Cooldown
// Grund: User hat explizit gespeichert → erwartet zeitnahen Sync
// Der eigene 5s-Throttle + isSyncing-Mutex reichen als Schutz
val isOnSaveSync = tags.contains(Constants.SYNC_ONSAVE_TAG)
// Globaler Cooldown-Check (nicht für onSave-Syncs)
if (!isOnSaveSync && !SyncStateManager.canSyncGlobally(prefs)) {
Logger.d(TAG, "⏭️ SyncWorker: Global sync cooldown active - skipping")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cooldown)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (!SyncStateManager.tryStartSync("worker-${tags.firstOrNull() ?: "unknown"}", silent = true)) {
Logger.d(TAG, "⏭️ SyncWorker: Another sync already in progress - skipping")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (already syncing)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
// Globalen Cooldown markieren
SyncStateManager.markGlobalSyncStarted(prefs)
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking for unsynced changes (Performance Pre-Check)")
}
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
@@ -122,7 +158,7 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2.5: Checking sync gate (canSync)")
Logger.d(TAG, "📍 Step 4: Checking sync gate (canSync)")
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
@@ -143,7 +179,7 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
Logger.d(TAG, "📍 Step 5: Checking server reachability (Pre-Check)")
}
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
@@ -167,7 +203,7 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync")
Logger.d(TAG, "📍 Step 6: Server reachable - proceeding with sync")
Logger.d(TAG, " SyncService: $syncService")
}
@@ -188,7 +224,7 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Processing result")
Logger.d(TAG, "📍 Step 7: Processing result")
Logger.d(
TAG,
"📦 Sync result: success=${result.isSuccess}, " +
@@ -198,10 +234,13 @@ class SyncWorker(
if (result.isSuccess) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Success path")
Logger.d(TAG, "📍 Step 8: Success path")
}
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
SyncStateManager.markCompleted()
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
if (result.syncedCount > 0) {
@@ -248,9 +287,13 @@ class SyncWorker(
Result.success()
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 5: Failure path")
Logger.d(TAG, "📍 Step 8: Failure path")
}
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
// 🆕 v1.8.1 (IMPL_08): SyncStateManager aktualisieren
SyncStateManager.markError(result.errorMessage)
NotificationHelper.showSyncError(
applicationContext,
result.errorMessage ?: "Unbekannter Fehler"

View File

@@ -27,6 +27,16 @@ class WifiSyncReceiver : BroadcastReceiver() {
return
}
// 🆕 v1.8.1 (IMPL_08): Globaler Cooldown (verhindert Doppel-Trigger mit NetworkMonitor)
if (!SyncStateManager.canSyncGlobally(prefs)) {
return
}
// 🆕 v1.8.1 (IMPL_08): Auch KEY_SYNC_TRIGGER_WIFI_CONNECT prüfen (Konsistenz mit NetworkMonitor)
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
return
}
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToWifi(context)) {
scheduleSyncWork(context)

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
* v1.5.0: NoteEditor Redesign
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag)
* v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt)
* v1.8.1: IMPL_14 - Separator als eigenes Item, Cross-Boundary-Drag mit Auto-Toggle
*/
class DragDropListState(
private val state: LazyListState,
@@ -36,8 +37,14 @@ class DragDropListState(
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableFloatStateOf(0f)
// 🆕 v1.8.1: Item-Größe beim Drag-Start fixieren
// Verhindert dass Höhenänderungen die Swap-Erkennung destabilisieren
private var draggingItemSize by mutableStateOf(0)
private var overscrollJob by mutableStateOf<Job?>(null)
// 🆕 v1.8.1 IMPL_14: Visual-Index des Separators (-1 = kein Separator)
var separatorVisualIndex by mutableStateOf(-1)
val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
@@ -47,9 +54,28 @@ class DragDropListState(
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex }
/**
* 🆕 v1.8.1 IMPL_14: Visual-Index → Data-Index Konvertierung.
* Wenn ein Separator existiert, sind alle Items nach dem Separator um 1 verschoben.
*/
fun visualToDataIndex(visualIndex: Int): Int {
if (separatorVisualIndex < 0) return visualIndex
return if (visualIndex > separatorVisualIndex) visualIndex - 1 else visualIndex
}
/**
* 🆕 v1.8.1 IMPL_14: Data-Index → Visual-Index Konvertierung.
*/
fun dataToVisualIndex(dataIndex: Int): Int {
if (separatorVisualIndex < 0) return dataIndex
return if (dataIndex >= separatorVisualIndex) dataIndex + 1 else dataIndex
}
fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f
val info = draggingItemLayoutInfo
draggingItemInitialOffset = info?.offset?.toFloat() ?: 0f
draggingItemSize = info?.size ?: 0
draggingItemDraggedDelta = 0f
}
@@ -57,6 +83,7 @@ class DragDropListState(
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0f
draggingItemSize = 0
overscrollJob?.cancel()
}
@@ -65,15 +92,19 @@ class DragDropListState(
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
// 🆕 v1.8.1: Fixierte Item-Größe für stabile Swap-Erkennung
val endOffset = startOffset + draggingItemSize
// 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
// wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
// Dies verhindert Oszillation bei Items unterschiedlicher Größe.
// Zusätzlich: Nur adjazente Items (Index ± 1) als Swap-Kandidaten.
// 🆕 v1.8.1 IMPL_14: Separator überspringen, Adjazenz berücksichtigt Separator-Lücke
val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
(item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) &&
// Separator überspringen
item.index != separatorVisualIndex &&
// Nur adjazente Items (Separator-Lücke wird übersprungen)
isAdjacentSkippingSeparator(draggingItem.index, item.index) &&
run {
val targetCenter = item.offset + item.size / 2
startOffset < targetCenter && endOffset > targetCenter
@@ -88,16 +119,20 @@ class DragDropListState(
} else {
null
}
// 🆕 v1.8.1 IMPL_14: Visual-Indizes zu Data-Indizes konvertieren für onMove
val fromDataIndex = visualToDataIndex(draggingItem.index)
val toDataIndex = visualToDataIndex(targetItem.index)
if (scrollToIndex != null) {
scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove(draggingItem.index, targetItem.index)
onMove(fromDataIndex, toDataIndex)
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
draggingItemIndex = targetItem.index
}
} else {
onMove(draggingItem.index, targetItem.index)
onMove(fromDataIndex, toDataIndex)
draggingItemIndex = targetItem.index
}
} else {
@@ -121,6 +156,26 @@ class DragDropListState(
}
}
/**
* 🆕 v1.8.1 IMPL_14: Prüft ob zwei Visual-Indizes adjazent sind,
* wobei der Separator übersprungen wird.
* Beispiel: Items bei Visual 1 und Visual 3 sind adjazent wenn Separator bei Visual 2 liegt.
*/
private fun isAdjacentSkippingSeparator(indexA: Int, indexB: Int): Boolean {
val diff = kotlin.math.abs(indexA - indexB)
if (diff == 1) {
// Direkt benachbart — aber NICHT wenn der Separator dazwischen liegt
val between = minOf(indexA, indexB) + 1
return between != separatorVisualIndex || separatorVisualIndex < 0
}
if (diff == 2 && separatorVisualIndex >= 0) {
// 2 Positionen entfernt — adjazent wenn Separator dazwischen
val between = minOf(indexA, indexB) + 1
return between == separatorVisualIndex
}
return false
}
@Suppress("UnusedPrivateProperty")
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size

View File

@@ -1,11 +1,6 @@
package dev.dettmer.simplenotes.ui.editor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -19,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -70,8 +66,10 @@ import dev.dettmer.simplenotes.utils.showToast
import kotlin.math.roundToInt
private const val LAYOUT_DELAY_MS = 100L
private const val AUTO_SCROLL_DELAY_MS = 50L
private const val ITEM_CORNER_RADIUS_DP = 8
private const val DRAGGING_ITEM_Z_INDEX = 10f
private val DRAGGING_ELEVATION_DP = 8.dp
/**
* Main Composable for the Note Editor screen.
@@ -327,6 +325,66 @@ private fun TextNoteContent(
)
}
/**
* 🆕 v1.8.1 IMPL_14: Extrahiertes Composable für ein einzelnes draggbares Checklist-Item.
* Entkoppelt von der Separator-Logik — wiederverwendbar für unchecked und checked Items.
*/
@Suppress("LongParameterList") // Compose callbacks — cannot be reduced without wrapper class
@Composable
private fun LazyItemScope.DraggableChecklistItem(
item: ChecklistItemState,
visualIndex: Int,
dragDropState: DragDropListState,
focusNewItemId: String?,
onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit,
onAddNewItemAfter: (String) -> Unit,
onFocusHandled: () -> Unit,
onHeightChanged: () -> Unit, // 🆕 v1.8.1 (IMPL_05)
) {
val isDragging = dragDropState.draggingItemIndex == visualIndex
val elevation by animateDpAsState(
targetValue = if (isDragging) DRAGGING_ELEVATION_DP else 0.dp,
label = "elevation"
)
val shouldFocus = item.id == focusNewItemId
LaunchedEffect(shouldFocus) {
if (shouldFocus) {
onFocusHandled()
}
}
ChecklistItemRow(
item = item,
onTextChange = { onTextChange(item.id, it) },
onCheckedChange = { onCheckedChange(item.id, it) },
onDelete = { onDelete(item.id) },
onAddNewItem = { onAddNewItemAfter(item.id) },
requestFocus = shouldFocus,
isDragging = isDragging,
isAnyItemDragging = dragDropState.draggingItemIndex != null,
dragModifier = Modifier.dragContainer(dragDropState, visualIndex),
onHeightChanged = onHeightChanged, // 🆕 v1.8.1 (IMPL_05)
modifier = Modifier
.then(if (!isDragging) Modifier.animateItem() else Modifier)
.offset {
IntOffset(
0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
)
}
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
)
)
}
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
@Composable
private fun ChecklistEditor(
@@ -351,6 +409,9 @@ private fun ChecklistEditor(
onMove = onMove
)
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll bei Zeilenumbruch
var scrollToItemIndex by remember { mutableStateOf<Int?>(null) }
// 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen
val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked }
@@ -359,70 +420,78 @@ private fun ChecklistEditor(
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
Column(modifier = modifier) {
// 🆕 v1.8.1 IMPL_14: Separator-Position für DragDropState aktualisieren
val separatorVisualIndex = if (showSeparator) uncheckedCount else -1
LaunchedEffect(separatorVisualIndex) {
dragDropState.separatorVisualIndex = separatorVisualIndex
}
// 🆕 v1.8.1 (IMPL_05): Auto-Scroll wenn ein Item durch Zeilenumbruch wächst
LaunchedEffect(scrollToItemIndex) {
scrollToItemIndex?.let { index ->
delay(AUTO_SCROLL_DELAY_MS) // Warten bis Layout-Pass abgeschlossen
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
if (index >= lastVisibleIndex - 1) {
listState.animateScrollToItem(
index = minOf(index + 1, items.size + if (showSeparator) 1 else 0),
scrollOffset = 0
)
}
scrollToItemIndex = null
}
}
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
itemsIndexed(
items = items,
items = if (showSeparator) items.subList(0, uncheckedCount) else items,
key = { _, item -> item.id }
) { index, item ->
// 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item
if (showSeparator && index == uncheckedCount) {
CheckedItemsSeparator(checkedCount = checkedCount)
}
val isDragging = dragDropState.draggingItemIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragging) 8.dp else 0.dp,
label = "elevation"
DraggableChecklistItem(
item = item,
visualIndex = index,
dragDropState = dragDropState,
focusNewItemId = focusNewItemId,
onTextChange = onTextChange,
onCheckedChange = onCheckedChange,
onDelete = onDelete,
onAddNewItemAfter = onAddNewItemAfter,
onFocusHandled = onFocusHandled,
onHeightChanged = { scrollToItemIndex = index } // 🆕 v1.8.1 (IMPL_05)
)
}
val shouldFocus = item.id == focusNewItemId
// v1.5.0: Clear focus request after handling
LaunchedEffect(shouldFocus) {
if (shouldFocus) {
onFocusHandled()
}
// 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
if (showSeparator) {
item(key = "separator") {
CheckedItemsSeparator(
checkedCount = checkedCount,
isDragActive = dragDropState.draggingItemIndex != null
)
}
// 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
ChecklistItemRow(
// 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
itemsIndexed(
items = items.subList(uncheckedCount, items.size),
key = { _, item -> item.id }
) { index, item ->
val visualIndex = uncheckedCount + 1 + index // +1 für Separator
DraggableChecklistItem(
item = item,
onTextChange = { onTextChange(item.id, it) },
onCheckedChange = { onCheckedChange(item.id, it) },
onDelete = { onDelete(item.id) },
onAddNewItem = { onAddNewItemAfter(item.id) },
requestFocus = shouldFocus,
// 🆕 v1.8.0: IMPL_023 - Drag state übergeben
isDragging = isDragging,
// 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden
isAnyItemDragging = dragDropState.draggingItemIndex != null,
// 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle
dragModifier = Modifier.dragContainer(dragDropState, index),
modifier = Modifier
.animateItem() // 🆕 v1.8.0 (IMPL_017): LazyColumn Item-Animation
.offset {
IntOffset(
0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
)
}
// 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
)
visualIndex = visualIndex,
dragDropState = dragDropState,
focusNewItemId = focusNewItemId,
onTextChange = onTextChange,
onCheckedChange = onCheckedChange,
onDelete = onDelete,
onAddNewItemAfter = onAddNewItemAfter,
onFocusHandled = onFocusHandled,
onHeightChanged = { scrollToItemIndex = visualIndex } // 🆕 v1.8.1 (IMPL_05)
)
}
}

View File

@@ -13,6 +13,7 @@ import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
@@ -90,64 +91,92 @@ class NoteEditorViewModel(
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
if (noteId != null) {
// Load existing note
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state ->
state.copy(
title = note.title,
content = note.content,
noteType = note.noteType,
isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else {
ToolbarTitle.EDIT_NOTE
}
)
}
if (note.noteType == NoteType.CHECKLIST) {
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: emptyList()
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
_checklistItems.value = sortChecklistItems(items)
}
}
loadExistingNote(noteId)
} else {
// New note
currentNoteType = try {
NoteType.valueOf(noteTypeString)
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
initNewNote(noteTypeString)
}
}
private fun loadExistingNote(noteId: String) {
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state ->
state.copy(
noteType = currentNoteType,
isNewNote = true,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
ToolbarTitle.NEW_CHECKLIST
title = note.title,
content = note.content,
noteType = note.noteType,
isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else {
ToolbarTitle.NEW_NOTE
ToolbarTitle.EDIT_NOTE
}
)
}
// Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
if (note.noteType == NoteType.CHECKLIST) {
loadChecklistData(note)
}
}
}
private fun loadChecklistData(note: Note) {
// 🆕 v1.8.1 (IMPL_03): Gespeicherte Sortierung laden
note.checklistSortOption?.let { sortName ->
_lastChecklistSortOption.value = parseSortOption(sortName)
}
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: emptyList()
// 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind)
_checklistItems.value = sortChecklistItems(items)
}
private fun initNewNote(noteTypeString: String) {
currentNoteType = try {
NoteType.valueOf(noteTypeString)
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT
}
_uiState.update { state ->
state.copy(
noteType = currentNoteType,
isNewNote = true,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
ToolbarTitle.NEW_CHECKLIST
} else {
ToolbarTitle.NEW_NOTE
}
)
}
// Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
}
}
/**
* Safely parse a ChecklistSortOption from its string name.
* Falls back to MANUAL if the name is unknown (e.g., from older app versions).
*/
private fun parseSortOption(sortName: String): ChecklistSortOption {
return try {
ChecklistSortOption.valueOf(sortName)
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
Logger.w(TAG, "Unknown sort option '$sortName', using MANUAL")
ChecklistSortOption.MANUAL
}
}
// ═══════════════════════════════════════════════════════════════════════
// Actions
@@ -173,11 +202,28 @@ class NoteEditorViewModel(
* 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten.
* Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten.
*/
/**
* Sortiert Checklist-Items basierend auf der aktuellen Sortier-Option.
* 🆕 v1.8.1 (IMPL_03-FIX): Berücksichtigt jetzt _lastChecklistSortOption
* anstatt immer unchecked-first zu sortieren.
*/
private fun sortChecklistItems(items: List<ChecklistItemState>): List<ChecklistItemState> {
val unchecked = items.filter { !it.isChecked }
val checked = items.filter { it.isChecked }
val sorted = when (_lastChecklistSortOption.value) {
ChecklistSortOption.MANUAL,
ChecklistSortOption.UNCHECKED_FIRST -> {
val unchecked = items.filter { !it.isChecked }
val checked = items.filter { it.isChecked }
unchecked + checked
}
ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked }
ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() }
ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() }
}
return (unchecked + checked).mapIndexed { index, item ->
return sorted.mapIndexed { index, item ->
item.copy(order = index)
}
}
@@ -198,13 +244,34 @@ class NoteEditorViewModel(
}
}
/**
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
*
* Guard: Bei MANUAL/UNCHECKED_FIRST wird sichergestellt, dass das neue (unchecked)
* Item nicht innerhalb der checked-Sektion eingefügt wird. Falls das Trigger-Item
* checked ist, wird stattdessen vor dem ersten checked Item eingefügt.
*/
fun addChecklistItemAfter(afterItemId: String): String {
val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items ->
val index = items.indexOfFirst { it.id == afterItemId }
if (index >= 0) {
val currentSort = _lastChecklistSortOption.value
val hasSeparator = currentSort == ChecklistSortOption.MANUAL ||
currentSort == ChecklistSortOption.UNCHECKED_FIRST
// 🆕 v1.8.1 (IMPL_15): Wenn das Trigger-Item checked ist und ein Separator
// existiert, darf das neue unchecked Item nicht in die checked-Sektion.
// → Stattdessen vor dem ersten checked Item einfügen.
val effectiveIndex = if (hasSeparator && items[index].isChecked) {
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
if (firstCheckedIndex >= 0) firstCheckedIndex else index + 1
} else {
index + 1
}
val newList = items.toMutableList()
newList.add(index + 1, newItem)
newList.add(effectiveIndex, newItem)
// Update order values
newList.mapIndexed { i, item -> item.copy(order = i) }
} else {
@@ -213,12 +280,46 @@ class NoteEditorViewModel(
}
return newItem.id
}
/**
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item an der semantisch korrekten Position ein.
*
* Bei MANUAL/UNCHECKED_FIRST: Vor dem ersten checked Item (= direkt über dem Separator).
* Bei allen anderen Modi: Am Ende der Liste (kein Separator sichtbar).
*
* Verhindert, dass checked Items über den Separator springen oder das neue Item
* unter dem Separator erscheint.
*/
fun addChecklistItemAtEnd(): String {
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
_checklistItems.update { items -> items + newItem }
val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items ->
val insertIndex = calculateInsertIndexForNewItem(items)
val newList = items.toMutableList()
newList.add(insertIndex, newItem)
newList.mapIndexed { i, item -> item.copy(order = i) }
}
return newItem.id
}
/**
* 🆕 v1.8.1 (IMPL_15): Berechnet die korrekte Insert-Position für ein neues unchecked Item.
*
* - MANUAL / UNCHECKED_FIRST: Vor dem ersten checked Item (direkt über dem Separator)
* - Alle anderen Modi: Am Ende der Liste (kein Separator, kein visuelles Problem)
*
* Falls keine checked Items existieren, wird am Ende eingefügt.
*/
private fun calculateInsertIndexForNewItem(items: List<ChecklistItemState>): Int {
val currentSort = _lastChecklistSortOption.value
return when (currentSort) {
ChecklistSortOption.MANUAL,
ChecklistSortOption.UNCHECKED_FIRST -> {
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
}
else -> items.size
}
}
fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items ->
@@ -238,15 +339,18 @@ class NoteEditorViewModel(
val fromItem = items.getOrNull(fromIndex) ?: return@update items
val toItem = items.getOrNull(toIndex) ?: return@update items
// 🆕 v1.8.0 (IMPL_017): Drag nur innerhalb der gleichen Gruppe erlauben
// (checked ↔ checked, unchecked ↔ unchecked)
if (fromItem.isChecked != toItem.isChecked) {
return@update items // Kein Move über Gruppen-Grenze
}
val mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex)
mutableList.add(toIndex, item)
// 🆕 v1.8.1 IMPL_14: Cross-Boundary Move mit Auto-Toggle
// Wenn ein Item die Grenze überschreitet, wird es automatisch checked/unchecked.
val movedItem = if (fromItem.isChecked != toItem.isChecked) {
item.copy(isChecked = toItem.isChecked)
} else {
item
}
mutableList.add(toIndex, movedItem)
// Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) }
}
@@ -348,6 +452,7 @@ class NoteEditorViewModel(
content = "", // Empty for checklists
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
@@ -357,6 +462,7 @@ class NoteEditorViewModel(
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
checklistSortOption = _lastChecklistSortOption.value.name, // 🆕 v1.8.1 (IMPL_03)
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY
)
@@ -366,7 +472,7 @@ class NoteEditorViewModel(
}
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
// 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync()
@@ -406,17 +512,33 @@ class NoteEditorViewModel(
val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId)
}
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
if (success) {
Logger.d(TAG, "Note $noteId deleted from server")
SyncStateManager.showInfo(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
)
)
} else {
Logger.w(TAG, "Failed to delete note $noteId from server")
SyncStateManager.showError(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_server_delete_failed
)
)
}
} catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}")
SyncStateManager.showError(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_server_error,
e.message ?: ""
)
)
}
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_DELETED))
_events.emit(NoteEditorEvent.NavigateBack)
}
}
@@ -513,6 +635,7 @@ class NoteEditorViewModel(
Logger.d(TAG, "📤 Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(Constants.SYNC_WORK_TAG)
.addTag(Constants.SYNC_ONSAVE_TAG) // 🆕 v1.8.1 (IMPL_08B): Bypassed globalen Cooldown
.build()
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
}

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

View File

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

View File

@@ -44,11 +44,12 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* Main Activity with Jetpack Compose UI
* v1.5.0: Complete MainActivity Redesign with Compose
*
*
* Replaces the old 805-line MainActivity.kt with a modern
* Compose-based implementation featuring:
* - Notes list with swipe-to-delete
@@ -58,22 +59,22 @@ import kotlinx.coroutines.launch
* - Design consistent with ComposeSettingsActivity
*/
class ComposeMainActivity : ComponentActivity() {
companion object {
private const val TAG = "ComposeMainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
private const val REQUEST_SETTINGS = 1002
}
private val viewModel: MainViewModel by viewModels()
private val viewModel: MainViewModel by viewModel()
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
// Phase 3: Track if coming from editor to scroll to top
private var cameFromEditor = false
/**
* BroadcastReceiver for Background-Sync Completion (Periodic Sync)
*/
@@ -81,9 +82,9 @@ class ComposeMainActivity : ComponentActivity() {
override fun onReceive(context: Context?, intent: Intent?) {
val success = intent?.getBooleanExtra("success", false) ?: false
val count = intent?.getIntExtra("count", 0) ?: 0
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
// UI refresh
if (success && count > 0) {
viewModel.loadNotes()
@@ -91,60 +92,60 @@ class ComposeMainActivity : ComponentActivity() {
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
// Install Splash Screen (Android 12+)
installSplashScreen()
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Material You (Android 12+)
DynamicColors.applyToActivityIfAvailable(this)
// Enable edge-to-edge display
enableEdgeToEdge()
// Initialize Logger and enable file logging if configured
Logger.init(this)
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
}
// Clear old sync notifications on app start
NotificationHelper.clearSyncNotifications(this)
// Request notification permission (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission()
}
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
// Setup Sync State Observer
setupSyncStateObserver()
setContent {
SimpleNotesTheme {
val context = LocalContext.current
// Dialog state for delete confirmation
var deleteDialogData by remember { mutableStateOf<MainViewModel.DeleteDialogData?>(null) }
// Handle delete dialog events
LaunchedEffect(Unit) {
viewModel.showDeleteDialog.collect { data ->
deleteDialogData = data
}
}
// Handle toast events
LaunchedEffect(Unit) {
viewModel.showToast.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// Delete confirmation dialog
deleteDialogData?.let { data ->
DeleteConfirmationDialog(
@@ -163,70 +164,70 @@ class ComposeMainActivity : ComponentActivity() {
}
)
}
MainScreen(
viewModel = viewModel,
onOpenNote = { noteId -> openNoteEditor(noteId) },
onOpenSettings = { openSettings() },
onCreateNote = { noteType -> createNote(noteType) }
)
// v1.8.0: Post-Update Changelog (shows once after update)
UpdateChangelogSheet()
}
}
}
override fun onResume() {
super.onResume()
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
// This ensures UI reflects current offline mode when returning from Settings
viewModel.refreshOfflineModeState()
// 🎨 v1.7.0: Refresh display mode when returning from Settings
viewModel.refreshDisplayMode()
// Register BroadcastReceiver for Background-Sync
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
)
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes
viewModel.loadNotes()
// Phase 3: Scroll to top if coming from editor (new/edited note)
if (cameFromEditor) {
viewModel.scrollToTop()
cameFromEditor = false
Logger.d(TAG, "📜 Came from editor - scrolling to top")
}
// Trigger Auto-Sync on app resume
viewModel.triggerAutoSync("onResume")
}
override fun onPause() {
super.onPause()
// Unregister BroadcastReceiver
@Suppress("DEPRECATION")
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun setupSyncStateObserver() {
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status)
}
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
lifecycleScope.launch {
SyncStateManager.syncProgress.collect { progress ->
@@ -236,6 +237,11 @@ class ComposeMainActivity : ComponentActivity() {
kotlinx.coroutines.delay(2000L)
SyncStateManager.reset()
}
// 🆕 v1.8.1 (IMPL_12): INFO-Meldungen nach 2.5s ausblenden
dev.dettmer.simplenotes.sync.SyncPhase.INFO -> {
kotlinx.coroutines.delay(2500L)
SyncStateManager.reset()
}
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
kotlinx.coroutines.delay(4000L)
SyncStateManager.reset()
@@ -245,14 +251,14 @@ class ComposeMainActivity : ComponentActivity() {
}
}
}
private fun openNoteEditor(noteId: String?) {
cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
noteId?.let {
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
}
// v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation(
this,
@@ -261,12 +267,12 @@ class ComposeMainActivity : ComponentActivity() {
)
startActivity(intent, options.toBundle())
}
private fun createNote(noteType: NoteType) {
cameFromEditor = true
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
// v1.5.0: Add slide animation
val options = ActivityOptions.makeCustomAnimation(
this,
@@ -275,7 +281,7 @@ class ComposeMainActivity : ComponentActivity() {
)
startActivity(intent, options.toBundle())
}
private fun openSettings() {
val intent = Intent(this, ComposeSettingsActivity::class.java)
val options = ActivityOptions.makeCustomAnimation(
@@ -286,10 +292,10 @@ class ComposeMainActivity : ComponentActivity() {
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS, options.toBundle())
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
@@ -298,29 +304,29 @@ class ComposeMainActivity : ComponentActivity() {
}
}
}
/**
* v1.4.1: Migrates existing checklists for backwards compatibility.
*/
private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done"
// Only run once
if (prefs.getBoolean(migrationKey, false)) {
return
}
val storage = NotesStorage(this)
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
val updatedNote = note.copy(
syncStatus = SyncStatus.PENDING
@@ -328,24 +334,24 @@ class ComposeMainActivity : ComponentActivity() {
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Mark migration as done
prefs.edit().putBoolean(migrationKey, true).apply()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
// Settings changed, reload notes
viewModel.loadNotes()
}
}
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onRequestPermissionsResult(
@@ -354,15 +360,15 @@ class ComposeMainActivity : ComponentActivity() {
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, getString(R.string.toast_notifications_enabled), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this,
getString(R.string.toast_notifications_disabled),
Toast.makeText(this,
getString(R.string.toast_notifications_disabled),
Toast.LENGTH_SHORT
).show()
}
@@ -384,8 +390,8 @@ private fun DeleteConfirmationDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.legacy_delete_dialog_title)) },
text = {
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
text = {
Text(stringResource(R.string.legacy_delete_dialog_message, noteTitle))
},
dismissButton = {
TextButton(onClick = onDismiss) {

View File

@@ -59,13 +59,14 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner
import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
/**
* Main screen displaying the notes list
* v1.5.0: Jetpack Compose MainActivity Redesign
*
*
* Performance optimized with proper state handling:
* - LazyListState for scroll control
* - Scaffold FAB slot for proper z-ordering
@@ -74,7 +75,7 @@ private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
viewModel: MainViewModel = koinViewModel(),
onOpenNote: (String?) -> Unit,
onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit
@@ -82,37 +83,37 @@ fun MainScreen(
val notes by viewModel.sortedNotes.collectAsState()
val syncState by viewModel.syncState.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState()
// 🆕 v1.8.0: Einziges Banner-System
val syncProgress by viewModel.syncProgress.collectAsState()
// Multi-Select State
val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
// 🌟 v1.6.0: Reactive offline mode state
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
// 🎨 v1.7.0: Display mode (list or grid)
val displayMode by viewModel.displayMode.collectAsState()
// Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) }
// 🆕 v1.8.0: Sync status legend dialog
var showSyncLegend by remember { mutableStateOf(false) }
// 🔀 v1.8.0: Sort dialog state
var showSortDialog by remember { mutableStateOf(false) }
val sortOption by viewModel.sortOption.collectAsState()
val sortDirection by viewModel.sortDirection.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// 🎨 v1.7.0: gridState für Staggered Grid Layout
val gridState = rememberLazyStaggeredGridState()
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
var timestampTicker by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
@@ -121,17 +122,17 @@ fun MainScreen(
timestampTicker = System.currentTimeMillis()
}
}
// Compute isSyncing once
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
// which is called in ComposeMainActivity.onResume() when returning from Settings
val hasServerConfig = viewModel.hasServerConfig()
val isSyncAvailable = !isOfflineMode && hasServerConfig
val canSync = isSyncAvailable && !isSyncing
// Handle snackbar events from ViewModel
LaunchedEffect(Unit) {
viewModel.showSnackbar.collect { data ->
@@ -147,7 +148,7 @@ fun MainScreen(
}
}
}
// Phase 3: Scroll to top when new note created
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
LaunchedEffect(scrollToTop) {
@@ -160,7 +161,7 @@ fun MainScreen(
viewModel.resetScrollToTop()
}
}
// v1.5.0 Hotfix: FAB manuell mit zIndex platzieren für garantierte Sichtbarkeit
Scaffold(
topBar = {
@@ -213,7 +214,7 @@ fun MainScreen(
progress = syncProgress,
modifier = Modifier.fillMaxWidth()
)
// Content: Empty state or notes list
if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f))
@@ -249,7 +250,7 @@ fun MainScreen(
listState = listState,
modifier = Modifier.weight(1f),
onNoteClick = { note -> onOpenNote(note.id) },
onNoteLongPress = { note ->
onNoteLongPress = { note ->
// Long-press starts selection mode
viewModel.startSelectionMode(note.id)
},
@@ -260,7 +261,7 @@ fun MainScreen(
}
}
}
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
AnimatedVisibility(
visible = !isSelectionMode,
@@ -277,7 +278,7 @@ fun MainScreen(
}
}
}
// Batch Delete Confirmation Dialog
if (showBatchDeleteDialog) {
DeleteConfirmationDialog(
@@ -294,14 +295,14 @@ fun MainScreen(
}
)
}
// 🆕 v1.8.0: Sync Status Legend Dialog
if (showSyncLegend) {
SyncStatusLegendDialog(
onDismiss = { showSyncLegend = false }
)
}
// 🔀 v1.8.0: Sort Dialog
if (showSortDialog) {
SortDialog(
@@ -344,7 +345,7 @@ private fun MainTopBar(
contentDescription = stringResource(R.string.sort_notes)
)
}
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
if (showSyncLegend) {
IconButton(onClick = onSyncLegendClick) {

View File

@@ -2,7 +2,9 @@ package dev.dettmer.simplenotes.ui.main
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection
@@ -31,50 +33,50 @@ import kotlinx.coroutines.withContext
/**
* ViewModel for MainActivity Compose
* v1.5.0: Jetpack Compose MainActivity Redesign
*
*
* Manages notes list, sync state, and deletion with undo.
*/
class MainViewModel(application: Application) : AndroidViewModel(application) {
class MainViewModel(
private val storage: NotesStorage,
private val prefs: SharedPreferences
) : ViewModel() {
companion object {
private const val TAG = "MainViewModel"
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
}
private val storage = NotesStorage(application)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════
// Notes State
// ═══════════════════════════════════════════════════════════════════════
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _pendingDeletions = MutableStateFlow<Set<String>>(emptySet())
val pendingDeletions: StateFlow<Set<String>> = _pendingDeletions.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Multi-Select State (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════
private val _selectedNotes = MutableStateFlow<Set<String>>(emptySet())
val selectedNotes: StateFlow<Set<String>> = _selectedNotes.asStateFlow()
val isSelectionMode: StateFlow<Boolean> = _selectedNotes
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// ═══════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline Mode State (reactive)
// ═══════════════════════════════════════════════════════════════════════
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
/**
* Refresh offline mode state from SharedPreferences
* Called when returning from Settings screen (in onResume)
@@ -85,16 +87,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_isOfflineMode.value = newValue
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode State
// ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
/**
* Refresh display mode from SharedPreferences
* Called when returning from Settings screen
@@ -104,25 +106,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_displayMode.value = newValue
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sort State
// ═══════════════════════════════════════════════════════════════════════
private val _sortOption = MutableStateFlow(
SortOption.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
)
)
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
private val _sortDirection = MutableStateFlow(
SortDirection.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
)
)
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
/**
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
* Reagiert automatisch auf Änderungen in allen drei Flows.
@@ -138,68 +140,68 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// ═══════════════════════════════════════════════════════════════════════
// Sync State
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
val syncProgress: StateFlow<SyncProgress> = SyncStateManager.syncProgress
// Intern: SyncState für PullToRefresh-Indikator
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI Events
// ═══════════════════════════════════════════════════════════════════════
private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
private val _showDeleteDialog = MutableSharedFlow<DeleteDialogData>()
val showDeleteDialog: SharedFlow<DeleteDialogData> = _showDeleteDialog.asSharedFlow()
private val _showSnackbar = MutableSharedFlow<SnackbarData>()
val showSnackbar: SharedFlow<SnackbarData> = _showSnackbar.asSharedFlow()
// Phase 3: Scroll-to-top when new note is created
private val _scrollToTop = MutableStateFlow(false)
val scrollToTop: StateFlow<Boolean> = _scrollToTop.asStateFlow()
// Track first note ID to detect new notes
private var previousFirstNoteId: String? = null
// ═══════════════════════════════════════════════════════════════════════
// Data Classes
// ═══════════════════════════════════════════════════════════════════════
data class DeleteDialogData(
val note: Note,
val originalList: List<Note>
)
data class SnackbarData(
val message: String,
val actionLabel: String,
val onAction: () -> Unit
)
// ═══════════════════════════════════════════════════════════════════════
// Initialization
// ═══════════════════════════════════════════════════════════════════════
init {
// v1.5.0 Performance: Load notes asynchronously to avoid blocking UI
viewModelScope.launch(Dispatchers.IO) {
loadNotesAsync()
}
}
// ═══════════════════════════════════════════════════════════════════════
// Notes Actions
// ═══════════════════════════════════════════════════════════════════════
/**
* Load notes asynchronously on IO dispatcher
* This prevents UI blocking during app startup
@@ -207,24 +209,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadNotesAsync() {
val allNotes = storage.loadAllNotes()
val pendingIds = _pendingDeletions.value
val filteredNotes = allNotes.filter { it.id !in pendingIds }
val filteredNotes = allNotes.filter { it.id !in pendingIds }.map { Note(
id = it.id,
content = it.content
) }
withContext(Dispatchers.Main) {
// Phase 3: Detect if a new note was added at the top
val newFirstNoteId = filteredNotes.firstOrNull()?.id
if (newFirstNoteId != null &&
previousFirstNoteId != null &&
if (newFirstNoteId != null &&
previousFirstNoteId != null &&
newFirstNoteId != previousFirstNoteId) {
// New note at top → trigger scroll
_scrollToTop.value = true
Logger.d(TAG, "📜 New note detected at top, triggering scroll-to-top")
}
previousFirstNoteId = newFirstNoteId
_notes.value = filteredNotes
}
}
/**
* Public loadNotes - delegates to async version
*/
@@ -233,25 +238,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadNotesAsync()
}
}
/**
* Reset scroll-to-top flag after scroll completed
*/
fun resetScrollToTop() {
_scrollToTop.value = false
}
/**
* Force scroll to top (e.g., after returning from editor)
*/
fun scrollToTop() {
_scrollToTop.value = true
}
// ═══════════════════════════════════════════════════════════════════════
// Multi-Select Actions (v1.5.0)
// ═══════════════════════════════════════════════════════════════════════
/**
* Toggle selection of a note
*/
@@ -262,56 +267,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_selectedNotes.value + noteId
}
}
/**
* Start selection mode with initial note
*/
fun startSelectionMode(noteId: String) {
_selectedNotes.value = setOf(noteId)
}
/**
* Select all notes
*/
fun selectAllNotes() {
_selectedNotes.value = _notes.value.map { it.id }.toSet()
}
/**
* Clear selection and exit selection mode
*/
fun clearSelection() {
_selectedNotes.value = emptySet()
}
/**
* Get count of selected notes
*/
fun getSelectedCount(): Int = _selectedNotes.value.size
/**
* Delete all selected notes
*/
fun deleteSelectedNotes(deleteFromServer: Boolean) {
val selectedIds = _selectedNotes.value.toList()
val selectedNotes = _notes.value.filter { it.id in selectedIds }
if (selectedNotes.isEmpty()) return
// Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + selectedIds.toSet()
// Delete from storage
selectedNotes.forEach { note ->
storage.deleteNote(note.id)
}
// Clear selection
clearSelection()
// Reload notes
loadNotes()
// Show snackbar with undo
val count = selectedNotes.size
val message = if (deleteFromServer) {
@@ -319,7 +324,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else {
getString(R.string.snackbar_notes_deleted_local, count)
}
viewModelScope.launch {
_showSnackbar.emit(SnackbarData(
message = message,
@@ -328,7 +333,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
undoDeleteMultiple(selectedNotes)
}
))
@Suppress("MagicNumber") // Snackbar timing coordination
// If delete from server, actually delete after a short delay
// (to allow undo action before server deletion)
@@ -347,19 +352,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
/**
* Undo deletion of multiple notes
*/
private fun undoDeleteMultiple(notes: List<Note>) {
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - notes.map { it.id }.toSet()
// Restore to storage
notes.forEach { note ->
storage.saveNote(note)
}
// Reload notes
loadNotes()
}
@@ -370,10 +375,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/
fun onNoteLongPressDelete(note: Note) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
// Store original list for potential restore
val originalList = _notes.value.toList()
if (alwaysDeleteFromServer) {
// Auto-delete without dialog
deleteNoteConfirmed(note, deleteFromServer = true)
@@ -392,34 +397,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun onNoteSwipedToDelete(note: Note) {
onNoteLongPressDelete(note) // Delegate to long-press handler
}
/**
* Restore note after swipe (user cancelled dialog)
*/
fun restoreNoteAfterSwipe(originalList: List<Note>) {
_notes.value = originalList
}
/**
* Confirm note deletion (from dialog or auto-delete)
*/
fun deleteNoteConfirmed(note: Note, deleteFromServer: Boolean) {
// Add to pending deletions
_pendingDeletions.value = _pendingDeletions.value + note.id
// Delete from storage
storage.deleteNote(note.id)
// Reload notes
loadNotes()
// Show snackbar with undo
val message = if (deleteFromServer) {
getString(R.string.snackbar_note_deleted_server, note.title)
} else {
getString(R.string.snackbar_note_deleted_local, note.title)
}
viewModelScope.launch {
_showSnackbar.emit(SnackbarData(
message = message,
@@ -428,7 +433,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
undoDelete(note)
}
))
@Suppress("MagicNumber") // Snackbar timing
// If delete from server, actually delete after snackbar timeout
if (deleteFromServer) {
@@ -443,21 +448,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
/**
* Undo note deletion
*/
fun undoDelete(note: Note) {
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - note.id
// Restore to storage
storage.saveNote(note)
// Reload notes
loadNotes()
}
/**
* Actually delete note from server after snackbar dismissed
*/
@@ -468,21 +473,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId)
}
if (success) {
_showToast.emit(getString(R.string.snackbar_deleted_from_server))
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
} else {
_showToast.emit(getString(R.string.snackbar_server_delete_failed))
SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
}
} catch (e: Exception) {
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: ""))
SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
} finally {
// Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - noteId
}
}
}
/**
* Delete multiple notes from server with aggregated toast
* Shows single toast at the end instead of one per note
@@ -492,7 +498,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val webdavService = WebDavSyncService(getApplication())
var successCount = 0
var failCount = 0
noteIds.forEach { noteId ->
try {
val success = withContext(Dispatchers.IO) {
@@ -506,8 +512,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_pendingDeletions.value = _pendingDeletions.value - noteId
}
}
// Show aggregated toast
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
val message = when {
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
@@ -517,25 +523,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
successCount + failCount
)
}
_showToast.emit(message)
if (failCount > 0) {
SyncStateManager.showError(message)
} else {
SyncStateManager.showInfo(message)
}
}
}
/**
* Finalize deletion (remove from pending set)
*/
fun finalizeDeletion(noteId: String) {
_pendingDeletions.value = _pendingDeletions.value - noteId
}
// ═══════════════════════════════════════════════════════════════════════
// Sync Actions
// ═══════════════════════════════════════════════════════════════════════
fun updateSyncState(status: SyncStateManager.SyncStatus) {
_syncState.value = status.state
}
/**
* Trigger manual sync (from toolbar button or pull-to-refresh)
* v1.7.0: Uses central canSync() gate for WiFi-only check
@@ -554,7 +564,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
return
}
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (verhindert Auto-Sync direkt danach)
// Manueller Sync prüft NICHT den globalen Cooldown (User will explizit synchronisieren)
val prefs = getApplication<android.app.Application>().getSharedPreferences(
Constants.PREFS_NAME,
android.content.Context.MODE_PRIVATE
)
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
if (!SyncStateManager.tryStartSync(source)) {
@@ -570,7 +587,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
return
}
// 🆕 v1.8.1 (IMPL_08): Globalen Cooldown markieren (nach tryStartSync, vor Launch)
SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch {
try {
// Check for unsynced changes (Banner zeigt bereits PREPARING)
@@ -580,23 +600,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadNotes()
return@launch
}
// Check server reachability
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ $source Sync: Server not reachable")
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch
}
// Perform sync
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
if (result.isSuccess) {
// 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen
val bannerMessage = buildString {
@@ -621,7 +641,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
/**
* Trigger auto-sync (onResume)
* Only runs if server is configured and interval has passed
@@ -635,12 +655,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
return
}
// Throttling check
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (alle Trigger teilen sich diesen)
if (!SyncStateManager.canSyncGlobally(prefs)) {
return
}
// Throttling check (eigener 60s-Cooldown für onResume)
if (!canTriggerAutoSync()) {
return
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
@@ -652,19 +677,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
return
}
// v1.5.0: silent=true → kein Banner bei Auto-Sync
// 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar
if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
// 🆕 v1.8.1 (IMPL_08): Globalen Sync-Cooldown markieren
SyncStateManager.markGlobalSyncStarted(prefs)
viewModelScope.launch {
try {
// Check for unsynced changes
@@ -673,28 +701,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncStateManager.reset() // Silent → geht direkt auf IDLE
return@launch
}
// Check server reachability
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset() // Silent → kein Error-Banner
return@launch
}
// Perform sync
val result = withContext(Dispatchers.IO) {
syncService.syncNotes()
}
if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
// Silent Sync mit echten Änderungen → trotzdem markCompleted (wird silent behandelt)
// 🆕 v1.8.1 (IMPL_11): Kein Toast bei Silent-Sync
// Das Banner-System respektiert silent=true korrekt (markCompleted → IDLE)
// Toast wurde fälschlicherweise trotzdem angezeigt
SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes()
} else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes")
@@ -710,25 +739,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
private fun canTriggerAutoSync(): Boolean {
val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSyncTime
if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ Auto-sync throttled - wait ${remainingSeconds}s")
return false
}
return true
}
// ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sortierung
// ═══════════════════════════════════════════════════════════════════════
/**
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
*/
@@ -744,13 +773,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
}
return when (direction) {
SortDirection.ASCENDING -> notes.sortedWith(comparator)
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
}
}
/**
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
*/
@@ -759,7 +788,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
}
/**
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
*/
@@ -768,7 +797,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
}
/**
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
*/
@@ -776,16 +805,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val newDirection = _sortDirection.value.toggle()
setSortDirection(newDirection)
}
// ═══════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
private fun getString(resId: Int, vararg formatArgs: Any): String =
private fun getString(resId: Int, vararg formatArgs: Any): String =
getApplication<android.app.Application>().getString(resId, *formatArgs)
fun isServerConfigured(): Boolean {
// 🌟 v1.6.0: Use reactive offline mode state
if (_isOfflineMode.value) {
@@ -794,7 +823,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
/**
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
* Used for determining if sync would be available when offline mode is disabled

View File

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

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) {
NoteType.TEXT -> note.content
NoteType.CHECKLIST -> {
note.checklistItems
?.joinToString("\n") { item ->
val prefix = if (item.isChecked) "" else ""
"$prefix ${item.text}"
} ?: ""
// 🆕 v1.8.1 (IMPL_03 + IMPL_06): Sortierte Preview mit neuen Emojis
note.checklistItems?.let { items ->
generateChecklistPreview(items, note.checklistSortOption)
} ?: ""
}
},
style = MaterialTheme.typography.bodySmall,

View File

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

View File

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

View File

@@ -82,4 +82,11 @@ object Constants {
// 📋 v1.8.0: Post-Update Changelog
const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version"
// 🆕 v1.8.1 (IMPL_08): Globaler Sync-Cooldown (über alle Trigger hinweg)
const val KEY_LAST_GLOBAL_SYNC_TIME = "last_global_sync_timestamp"
const val MIN_GLOBAL_SYNC_INTERVAL_MS = 30_000L // 30 Sekunden
// 🆕 v1.8.1 (IMPL_08B): onSave-Sync Worker-Tag (bypassed globalen Cooldown)
const val SYNC_ONSAVE_TAG = "onsave"
}

View File

@@ -36,15 +36,20 @@ class NoteWidget : GlanceAppWidget() {
companion object {
// Responsive Breakpoints — schmale + breite Spalten
val SIZE_SMALL = DpSize(110.dp, 80.dp) // Schmal+kurz: nur Titel
val SIZE_NARROW_MEDIUM = DpSize(110.dp, 110.dp) // Schmal+mittel: Vorschau
val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt
val SIZE_WIDE_MEDIUM = DpSize(250.dp, 110.dp) // Breit+mittel: Vorschau
val SIZE_WIDE_LARGE = DpSize(250.dp, 250.dp) // Breit+groß: voller Inhalt
val SIZE_SMALL = DpSize(110.dp, 80.dp) // Schmal+kurz: nur Titel
val SIZE_NARROW_MEDIUM = DpSize(110.dp, 110.dp) // Schmal+mittel: Vorschau
val SIZE_NARROW_SCROLL = DpSize(110.dp, 150.dp) // 🆕 v1.8.1: Schmal+scroll (Standard 3x2)
val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt
val SIZE_WIDE_MEDIUM = DpSize(250.dp, 110.dp) // Breit+mittel: Vorschau
val SIZE_WIDE_SCROLL = DpSize(250.dp, 150.dp) // 🆕 v1.8.1: Breit+scroll (Standard 3x2 breit)
val SIZE_WIDE_LARGE = DpSize(250.dp, 250.dp) // Breit+groß: voller Inhalt
}
override val sizeMode = SizeMode.Responsive(
setOf(SIZE_SMALL, SIZE_NARROW_MEDIUM, SIZE_NARROW_LARGE, SIZE_WIDE_MEDIUM, SIZE_WIDE_LARGE)
setOf(
SIZE_SMALL, SIZE_NARROW_MEDIUM, SIZE_NARROW_SCROLL, SIZE_NARROW_LARGE,
SIZE_WIDE_MEDIUM, SIZE_WIDE_SCROLL, SIZE_WIDE_LARGE
)
)
override val stateDefinition = PreferencesGlanceStateDefinition

View File

@@ -5,6 +5,7 @@ import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
@@ -51,14 +52,32 @@ class ToggleChecklistItemAction : ActionCallback {
} else item
} ?: return
// 🆕 v1.8.1 (IMPL_04): Auto-Sort nach Toggle
// Konsistent mit NoteEditorViewModel.updateChecklistItemChecked
val sortOption = try {
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
?: ChecklistSortOption.MANUAL
val sortedItems = if (sortOption == ChecklistSortOption.MANUAL ||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) {
val unchecked = updatedItems.filter { !it.isChecked }
val checked = updatedItems.filter { it.isChecked }
(unchecked + checked).mapIndexed { index, item ->
item.copy(order = index)
}
} else {
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
}
val updatedNote = note.copy(
checklistItems = updatedItems,
checklistItems = sortedItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, "Toggled checklist item '$itemId' in widget")
Logger.d(TAG, "Toggled + auto-sorted checklist item '$itemId' in widget")
// 🐛 FIX: Glance-State ändern um Re-Render zu erzwingen
updateAppWidgetState(context, glanceId) { prefs ->

View File

@@ -38,9 +38,11 @@ import androidx.glance.layout.width
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
import dev.dettmer.simplenotes.ui.main.components.sortChecklistItemsForPreview
/**
* 🆕 v1.8.0: Glance Composable Content für das Notiz-Widget
@@ -52,6 +54,7 @@ import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
// ── Size Classification ──
private val WIDGET_HEIGHT_SMALL_THRESHOLD = 110.dp
private val WIDGET_HEIGHT_SCROLL_THRESHOLD = 150.dp // 🆕 v1.8.1: Scrollbare Ansicht
private val WIDGET_SIZE_MEDIUM_THRESHOLD = 250.dp
// 🆕 v1.8.0: Increased preview lengths for better text visibility
@@ -59,11 +62,39 @@ private const val TEXT_PREVIEW_COMPACT_LENGTH = 120
private const val TEXT_PREVIEW_FULL_LENGTH = 300
private fun DpSize.toSizeClass(): WidgetSizeClass = when {
height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_MED
width < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_TALL
height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_MED
else -> WidgetSizeClass.WIDE_TALL
height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL
// 🆕 v1.8.1: Neue ScrollView-Schwelle bei 150dp Höhe
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.NARROW_MED
width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_SCROLL
width < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_TALL
height < WIDGET_HEIGHT_SCROLL_THRESHOLD -> WidgetSizeClass.WIDE_MED
height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_SCROLL
else -> WidgetSizeClass.WIDE_TALL
}
/**
* 🆕 v1.8.1 (IMPL_04): Separator zwischen erledigten und unerledigten Items im Widget.
* Glance-kompatible Version von CheckedItemsSeparator.
*/
@Composable
private fun WidgetCheckedItemsSeparator(checkedCount: Int) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "── $checkedCount ✔ ──",
style = TextStyle(
color = GlanceTheme.colors.outline,
fontSize = 11.sp
)
)
}
}
@Composable
@@ -177,14 +208,28 @@ fun NoteWidgetContent(
}
}
WidgetSizeClass.NARROW_TALL -> Box(modifier = contentClickModifier) {
// 🆕 v1.8.1 (IMPL_09): Scrollbare Größe (150dp+ Höhe)
WidgetSizeClass.NARROW_SCROLL,
WidgetSizeClass.NARROW_TALL -> {
when (note.noteType) {
NoteType.TEXT -> TextNoteFullView(note)
NoteType.CHECKLIST -> ChecklistFullView(
note = note,
isLocked = isLocked,
glanceId = glanceId
)
NoteType.TEXT -> Box(modifier = contentClickModifier) {
TextNoteFullView(note)
}
NoteType.CHECKLIST -> {
// 🆕 v1.8.1: Locked: Click -> Options | Unlocked: kein Click -> Scroll frei
val checklistBoxModifier = if (isLocked) {
contentClickModifier
} else {
GlanceModifier.fillMaxSize()
}
Box(modifier = checklistBoxModifier) {
ChecklistFullView(
note = note,
isLocked = isLocked,
glanceId = glanceId
)
}
}
}
}
@@ -200,14 +245,28 @@ fun NoteWidgetContent(
}
}
WidgetSizeClass.WIDE_TALL -> Box(modifier = contentClickModifier) {
// 🆕 v1.8.1 (IMPL_09): Scrollbare Größe (150dp+ Höhe)
WidgetSizeClass.WIDE_SCROLL,
WidgetSizeClass.WIDE_TALL -> {
when (note.noteType) {
NoteType.TEXT -> TextNoteFullView(note)
NoteType.CHECKLIST -> ChecklistFullView(
note = note,
isLocked = isLocked,
glanceId = glanceId
)
NoteType.TEXT -> Box(modifier = contentClickModifier) {
TextNoteFullView(note)
}
NoteType.CHECKLIST -> {
// 🆕 v1.8.1: Locked: Click -> Options | Unlocked: kein Click -> Scroll frei
val checklistBoxModifier = if (isLocked) {
contentClickModifier
} else {
GlanceModifier.fillMaxSize()
}
Box(modifier = checklistBoxModifier) {
ChecklistFullView(
note = note,
isLocked = isLocked,
glanceId = glanceId
)
}
}
}
}
}
@@ -370,13 +429,35 @@ private fun ChecklistCompactView(
isLocked: Boolean,
glanceId: GlanceId
) {
val items = note.checklistItems?.sortedBy { it.order } ?: return
// 🆕 v1.8.1 (IMPL_04): Sortierung aus Editor übernehmen
val items = note.checklistItems?.let { rawItems ->
sortChecklistItemsForPreview(rawItems, note.checklistSortOption)
} ?: return
// 🆕 v1.8.1 (IMPL_04): Separator-Logik
val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked }
val sortOption = try {
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
?: ChecklistSortOption.MANUAL
val showSeparator = (sortOption == ChecklistSortOption.MANUAL ||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) &&
uncheckedCount > 0 && checkedCount > 0
val visibleItems = items.take(maxItems)
val remainingCount = items.size - visibleItems.size
val checkedCount = items.count { it.isChecked }
Column(modifier = GlanceModifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
var separatorShown = false
visibleItems.forEach { item ->
// 🆕 v1.8.1: Separator vor dem ersten checked Item anzeigen
if (showSeparator && !separatorShown && item.isChecked) {
WidgetCheckedItemsSeparator(checkedCount = checkedCount)
separatorShown = true
}
if (isLocked) {
Row(
modifier = GlanceModifier
@@ -385,7 +466,7 @@ private fun ChecklistCompactView(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (item.isChecked) "" else "",
text = if (item.isChecked) "☑️" else "", // 🆕 v1.8.1 (IMPL_06)
style = TextStyle(fontSize = 14.sp)
)
Spacer(modifier = GlanceModifier.width(6.dp))
@@ -443,15 +524,41 @@ private fun ChecklistFullView(
isLocked: Boolean,
glanceId: GlanceId
) {
val items = note.checklistItems?.sortedBy { it.order } ?: return
// 🆕 v1.8.1 (IMPL_04): Sortierung aus Editor übernehmen
val items = note.checklistItems?.let { rawItems ->
sortChecklistItemsForPreview(rawItems, note.checklistSortOption)
} ?: return
// 🆕 v1.8.1 (IMPL_04): Separator-Logik
val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked }
val sortOption = try {
note.checklistSortOption?.let { ChecklistSortOption.valueOf(it) }
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) { null }
?: ChecklistSortOption.MANUAL
val showSeparator = (sortOption == ChecklistSortOption.MANUAL ||
sortOption == ChecklistSortOption.UNCHECKED_FIRST) &&
uncheckedCount > 0 && checkedCount > 0
// 🆕 v1.8.1: Berechne die Gesamtanzahl der Elemente inklusive Separator
val totalItems = items.size + if (showSeparator) 1 else 0
LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
items(items.size) { index ->
val item = items[index]
items(totalItems) { index ->
// 🆕 v1.8.1: Separator an Position uncheckedCount einfügen
if (showSeparator && index == uncheckedCount) {
WidgetCheckedItemsSeparator(checkedCount = checkedCount)
return@items
}
// Tatsächlichen Item-Index berechnen (nach Separator um 1 verschoben)
val itemIndex = if (showSeparator && index > uncheckedCount) index - 1 else index
val item = items.getOrNull(itemIndex) ?: return@items
if (isLocked) {
Row(
@@ -461,7 +568,7 @@ private fun ChecklistFullView(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (item.isChecked) "" else "",
text = if (item.isChecked) "☑️" else "", // 🆕 v1.8.1 (IMPL_06)
style = TextStyle(fontSize = 16.sp)
)
Spacer(modifier = GlanceModifier.width(8.dp))

View File

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

View File

@@ -1,17 +0,0 @@
package dev.dettmer.simplenotes
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.editor
import dev.dettmer.simplenotes.models.ChecklistSortOption
import org.junit.Assert.*
import org.junit.Test
@@ -174,4 +175,204 @@ class ChecklistSortingTest {
assertEquals(1, sorted[1].order)
assertEquals(2, sorted[2].order)
}
// ═══════════════════════════════════════════════════════════════════════
// 🆕 v1.8.1 (IMPL_15): Tests für Add-Item Insert-Position
// ═══════════════════════════════════════════════════════════════════════
/**
* Simulates calculateInsertIndexForNewItem() from NoteEditorViewModel.
* Tests the insert position logic for new unchecked items.
*/
private fun calculateInsertIndexForNewItem(
items: List<ChecklistItemState>,
sortOption: ChecklistSortOption
): Int {
return when (sortOption) {
ChecklistSortOption.MANUAL,
ChecklistSortOption.UNCHECKED_FIRST -> {
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
}
else -> items.size
}
}
/**
* Simulates the full addChecklistItemAtEnd() logic:
* 1. Calculate insert index
* 2. Insert new item
* 3. Reassign order values
*/
private fun simulateAddItemAtEnd(
items: List<ChecklistItemState>,
sortOption: ChecklistSortOption
): List<ChecklistItemState> {
val newItem = ChecklistItemState(id = "new", text = "", isChecked = false, order = 0)
val insertIndex = calculateInsertIndexForNewItem(items, sortOption)
val newList = items.toMutableList()
newList.add(insertIndex, newItem)
return newList.mapIndexed { i, item -> item.copy(order = i) }
}
@Test
fun `IMPL_15 - add item at end inserts before separator in MANUAL mode`() {
// Ausgangslage: 2 unchecked, 1 checked (sortiert)
val items = listOf(
item("a", checked = false, order = 0),
item("b", checked = false, order = 1),
item("c", checked = true, order = 2)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
// Neues Item muss an Index 2 stehen (vor dem checked Item)
assertEquals(4, result.size)
assertEquals("a", result[0].id)
assertEquals("b", result[1].id)
assertEquals("new", result[2].id) // ← Neues Item VOR Separator
assertFalse(result[2].isChecked)
assertEquals("c", result[3].id) // ← Checked Item bleibt UNTER Separator
assertTrue(result[3].isChecked)
}
@Test
fun `IMPL_15 - add item at end inserts before separator in UNCHECKED_FIRST mode`() {
val items = listOf(
item("a", checked = false, order = 0),
item("b", checked = true, order = 1),
item("c", checked = true, order = 2)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.UNCHECKED_FIRST)
assertEquals(4, result.size)
assertEquals("a", result[0].id)
assertEquals("new", result[1].id) // ← Neues Item direkt nach letztem unchecked
assertFalse(result[1].isChecked)
assertEquals("b", result[2].id)
assertEquals("c", result[3].id)
}
@Test
fun `IMPL_15 - add item at end appends at end in CHECKED_FIRST mode`() {
val items = listOf(
item("a", checked = true, order = 0),
item("b", checked = false, order = 1)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.CHECKED_FIRST)
assertEquals(3, result.size)
assertEquals("a", result[0].id)
assertEquals("b", result[1].id)
assertEquals("new", result[2].id) // ← Am Ende (kein Separator)
}
@Test
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_ASC mode`() {
val items = listOf(
item("a", checked = false, order = 0),
item("b", checked = true, order = 1)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_ASC)
assertEquals(3, result.size)
assertEquals("new", result[2].id) // ← Am Ende
}
@Test
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_DESC mode`() {
val items = listOf(
item("a", checked = true, order = 0),
item("b", checked = false, order = 1)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_DESC)
assertEquals(3, result.size)
assertEquals("new", result[2].id) // ← Am Ende
}
@Test
fun `IMPL_15 - add item with no checked items appends at end`() {
val items = listOf(
item("a", checked = false, order = 0),
item("b", checked = false, order = 1)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
assertEquals(3, result.size)
assertEquals("new", result[2].id) // Kein checked Item → ans Ende
}
@Test
fun `IMPL_15 - add item with all checked items inserts at position 0`() {
val items = listOf(
item("a", checked = true, order = 0),
item("b", checked = true, order = 1)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
assertEquals(3, result.size)
assertEquals("new", result[0].id) // ← Ganz oben (vor allen checked Items)
assertFalse(result[0].isChecked)
assertEquals("a", result[1].id)
assertEquals("b", result[2].id)
}
@Test
fun `IMPL_15 - add item to empty list in MANUAL mode`() {
val items = emptyList<ChecklistItemState>()
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
assertEquals(1, result.size)
assertEquals("new", result[0].id)
assertEquals(0, result[0].order)
}
@Test
fun `IMPL_15 - order values are sequential after add item`() {
val items = listOf(
item("a", checked = false, order = 0),
item("b", checked = false, order = 1),
item("c", checked = true, order = 2)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
result.forEachIndexed { index, item ->
assertEquals("Order at index $index should be $index", index, item.order)
}
}
@Test
fun `IMPL_15 - existing items do not change position after add item`() {
// Kernforderung: Kein Item darf sich verschieben
val items = listOf(
item("cashews", checked = false, order = 0),
item("noodles", checked = false, order = 1),
item("coffee", checked = true, order = 2)
)
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
// Relative Reihenfolge der bestehenden Items prüfen
val existingIds = result.filter { it.id != "new" }.map { it.id }
assertEquals(listOf("cashews", "noodles", "coffee"), existingIds)
// Cashews und Noodles müssen VOR dem neuen Item sein
val cashewsIdx = result.indexOfFirst { it.id == "cashews" }
val noodlesIdx = result.indexOfFirst { it.id == "noodles" }
val newIdx = result.indexOfFirst { it.id == "new" }
val coffeeIdx = result.indexOfFirst { it.id == "coffee" }
assertTrue("Cashews before new", cashewsIdx < newIdx)
assertTrue("Noodles before new", noodlesIdx < newIdx)
assertTrue("New before Coffee", newIdx < coffeeIdx)
}
}

View File

@@ -1,8 +1,8 @@
# ⚡ v1.3.1: detekt Configuration
# ⚡ v1.8.1: detekt Configuration
# Pragmatic rules for simple-notes-sync
build:
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
maxIssues: 0 # v1.8.1: All issues resolved
excludeCorrectable: false
config:

View File

@@ -16,6 +16,9 @@ composeBom = "2026.01.00"
navigationCompose = "2.7.6"
lifecycleRuntimeCompose = "2.7.0"
activityCompose = "1.8.2"
room = "2.6.1"
ksp = "2.0.0-1.0.21"
koin = "3.5.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -37,6 +40,14 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
# Room Database
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Core Koin for Kotlin projects
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
# Koin for Jetpack Compose integration
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -44,4 +55,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

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

View File

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

View File

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

View File

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

View File

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

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)_
### Unterstützte Sprachen
@@ -129,9 +173,12 @@
### Sync-Mechanismus
-**Upload** - Lokale Änderungen zum Server
-**Download** - Server-Änderungen in App
-**Parallele Downloads** _(NEU in v1.8.0)_ - Bis zu 5 gleichzeitige Downloads
-**Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
-**Konfliktfreies Merging** - Last-Write-Wins via Timestamp
-**Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Server-Löschungs-Erkennung** _(NEU in v1.8.0)_ - Erkennt auf anderen Geräten gelöschte Notizen
-**Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT, DELETED_ON_SERVER
-**Live Fortschritts-UI** _(NEU in v1.8.0)_ - Phasen-Anzeige mit Upload/Download-Zählern
-**Fehlerbehandlung** - Retry bei Netzwerkproblemen
-**Offline-First** - App funktioniert ohne Server
@@ -140,6 +187,9 @@
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen
-**WiFi-Only Sync** _(NEU in v1.7.0)_ - Option nur über WiFi zu synchronisieren
-**VPN-Unterstützung** _(NEU in v1.7.0)_ - Sync funktioniert korrekt über VPN-Tunnels
-**Self-Signed SSL** _(NEU in v1.7.0)_ - Unterstützung für selbstsignierte Zertifikate
-**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
-**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
@@ -196,11 +246,12 @@
## 🛠️ Technische Details
### Plattform
- **Android 8.0+** (API 26+)
- **Android 7.0+** (API 24+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Moderne Programmiersprache
- **Jetpack Compose** - Deklaratives UI-Framework
- **Material Design 3** - Neueste Design-Richtlinien
- **ViewBinding** - Typ-sichere View-Referenzen
- **Jetpack Glance** _(v1.8.0)_ - Widget-Framework
### Architektur
- **MVVM-Light** - Einfache Architektur
@@ -218,6 +269,7 @@
- **Gson** - JSON Serialization
- **WorkManager** - Background Tasks
- **OkHttp** - HTTP Client (via Sardine)
- **Glance** _(v1.8.0)_ - Widget-Framework
### Build-Varianten
- **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
@@ -247,22 +299,12 @@
## 🔮 Zukünftige Features
Geplant für kommende Versionen:
Geplant für kommende Versionen siehe [UPCOMING.md](UPCOMING.md) für die vollständige Roadmap.
### v1.4.0 - Checklisten
- **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
- **Erledigte Items** - Durchstreichen/Abhaken
- **Drag & Drop** - Items neu anordnen
### v1.5.0 - Internationalisierung
- **Mehrsprachigkeit** - Deutsch + Englisch UI
- **Sprachauswahl** - In Einstellungen wählbar
- **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
### v1.6.0 - Modern APIs
- **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
- **PackageInfo Flags** - PackageInfoFlags.of() verwenden
- **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
### v2.0.0 - Legacy Cleanup
- **Veraltete Activities entfernen** - Durch Compose-Varianten ersetzen
- **LocalBroadcastManager → SharedFlow** - Moderne Event-Architektur
- **WebDavSyncService aufteilen** - SyncOrchestrator, NoteUploader, NoteDownloader
---
@@ -305,4 +347,4 @@ A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
---
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)
**Letzte Aktualisierung:** v1.8.1 (2026-02-11)

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)_
### Supported Languages
@@ -129,9 +173,12 @@
### Sync Mechanism
-**Upload** - Local changes to server
-**Download** - Server changes to app
-**Parallel downloads** _(NEW in v1.8.0)_ - Up to 5 simultaneous downloads
-**Conflict detection** - On simultaneous changes
-**Conflict-free merging** - Last-Write-Wins via timestamp
-**Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Server deletion detection** _(NEW in v1.8.0)_ - Detects notes deleted on other devices
-**Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT, DELETED_ON_SERVER
-**Live progress UI** _(NEW in v1.8.0)_ - Phase indicators with upload/download counters
-**Error handling** - Retry on network issues
-**Offline-first** - App works without server
@@ -140,6 +187,9 @@
-**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication
-**Connection test** - Test in settings
-**WiFi-only sync** _(NEW in v1.7.0)_ - Option to sync only on WiFi
-**VPN support** _(NEW in v1.7.0)_ - Sync works correctly through VPN tunnels
-**Self-signed SSL** _(NEW in v1.7.0)_ - Support for self-signed certificates
-**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
-**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
@@ -196,11 +246,12 @@
## 🛠️ Technical Details
### Platform
- **Android 8.0+** (API 26+)
- **Android 7.0+** (API 24+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Modern programming language
- **Jetpack Compose** - Declarative UI framework
- **Material Design 3** - Latest design guidelines
- **ViewBinding** - Type-safe view references
- **Jetpack Glance** _(v1.8.0)_ - Widget framework
### Architecture
- **MVVM-Light** - Simple architecture
@@ -218,6 +269,7 @@
- **Gson** - JSON serialization
- **WorkManager** - Background tasks
- **OkHttp** - HTTP client (via Sardine)
- **Glance** _(v1.8.0)_ - Widget framework
### Build Variants
- **Standard** - Universal APK (100% FOSS, no Google dependencies)
@@ -247,22 +299,12 @@
## 🔮 Future Features
Planned for upcoming versions:
Planned for upcoming versions see [UPCOMING.md](UPCOMING.md) for the full roadmap.
### v1.4.0 - Checklists
- **Checklist notes** - New note type with checkboxes
- **Completed items** - Strike-through/check off
- **Drag & drop** - Reorder items
### v1.5.0 - Internationalization
- **Multi-language** - German + English UI
- **Language selection** - Selectable in settings
- **Full translation** - All strings in both languages
### v1.6.0 - Modern APIs
- **Replace LocalBroadcastManager** - Use SharedFlow instead
- **PackageInfo Flags** - Use PackageInfoFlags.of()
- **Complexity refactoring** - Split long functions
### v2.0.0 - Legacy Cleanup
- **Remove deprecated Activities** - Replace with Compose equivalents
- **LocalBroadcastManager → SharedFlow** - Modern event architecture
- **WebDavSyncService split** - SyncOrchestrator, NoteUploader, NoteDownloader
---
@@ -305,4 +347,4 @@ A: Yes! Download the APK directly from GitHub or use F-Droid.
---
**Last update:** v1.3.2 (2026-01-10)
**Last update:** v1.8.1 (2026-02-11)

146
docs/SELF_SIGNED_SSL.de.md Normal file
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`
- **Intelligente Größen** - Kleine Notizen (kurzer Text, wenige Checklist-Items) kompakt dargestellt
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht in Einstellungen wechseln
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
- **120 FPS optimiert** - Lazy Loading für flüssiges Scrollen bei vielen Notizen
- **Pinterest-artiges Staggered Grid** - Lückenfreies Layout mit dynamischen Vorschauzeilen
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht wechseln
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
### 🔧 Server-Ordner Prüfung
### 📡 Sync-Verbesserungen
- **WebDAV Folder Check** - Prüft ob der Ordner auf dem Server existiert und beschreibbar ist
- **Bessere Fehlermeldungen** - Hilfreiche Hinweise bei Server-Problemen
- **Connection-Test Verbesserung** - Prüft Read/Write Permissions
- **WiFi-Only Sync Toggle** - Nur über WiFi synchronisieren
- **VPN-Unterstützung** - Sync funktioniert korrekt über VPN-Tunnels
- **Self-Signed SSL** - Dokumentation und Unterstützung für selbstsignierte Zertifikate
-**Server-Wechsel-Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei URL-Änderung
### 🔧 Technische Verbesserungen
---
- **Code-Refactoring** - LargeClass Komponenten aufteilen (WebDavSyncService, SettingsActivity)
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
## v1.7.1 - Android 9 Fix & VPN ✅
> **Status:** Released 🎉 (Februar 2026)
-**Android 9 Crash Fix** - `getForegroundInfo()` für WorkManager auf API 28 implementiert
-**VPN-Kompatibilität** - WiFi Socket-Binding erkennt Wireguard VPN-Interfaces
-**SafeSardineWrapper** - Saubere HTTP-Verbindungs-Bereinigung
---
## v1.7.2 - Timestamp & Löschungs-Fixes ✅
> **Status:** Released 🎉 (Februar 2026)
-**Server-mtime als Wahrheitsquelle** - Behebt Timestamp-Probleme mit externen Editoren
-**Deletion Tracker Mutex** - Thread-sichere Batch-Löschungen
-**ISO8601 Timezone-Parsing** - Multi-Format-Unterstützung
-**E-Tag Batch-Caching** - Performance-Verbesserung
-**Memory Leak Prävention** - SafeSardineWrapper mit Closeable
---
## v1.8.0 - Widgets, Sortierung & Erweiterter Sync ✅
> **Status:** Released 🎉 (Februar 2026)
### 📌 Homescreen-Widgets
-**Volles Jetpack Glance Framework** - 5 responsive Größenklassen
-**Interaktive Checklisten** - Checkboxen die zum Server synchronisieren
-**Material You Farben** - Dynamische Farben mit einstellbarer Opazität
-**Sperr-Umschalter** - Versehentliche Bearbeitungen verhindern
-**Konfigurations-Activity** - Notiz-Auswahl und Einstellungen
### 📊 Sortierung
-**Notiz-Sortierung** - Nach Titel, Änderungsdatum, Erstelldatum, Typ
-**Checklisten-Sortierung** - Manuell, alphabetisch, offene zuerst, erledigte zuletzt
-**Visuelle Trenner** - Zwischen offenen/erledigten Gruppen
-**Drag über Grenzen** - Auto-Toggle beim Überqueren des Trenners
### 🔄 Sync-Verbesserungen
-**Parallele Downloads** - Bis zu 5 gleichzeitig (konfigurierbar)
-**Server-Löschungs-Erkennung** - Erkennt auf anderen Clients gelöschte Notizen
-**Live Sync-Fortschritt** - Phasen-Anzeige mit Zählern
-**Sync-Status Legende** - Hilfe-Dialog für alle Sync-Icons
### ✨ UX
-**Post-Update Changelog** - Zeigt lokalisierten Changelog nach Update
-**Grid als Standard** - Neue Installationen starten im Grid-Modus
-**Toast → Banner Migration** - Einheitliches Benachrichtigungssystem
---
## v1.8.1 - Bugfix & Polish ✅
> **Status:** Released 🎉 (Februar 2026)
-**Checklisten-Sortierung Persistenz** - Sortier-Option korrekt wiederhergestellt
-**Widget Scroll Fix** - Scroll funktioniert auf Standard 3×2 Widget-Größe
-**Widget Checklisten-Sortierung** - Widgets übernehmen gespeicherte Sortier-Option
-**Drag Cross-Boundary** - Drag & Drop über Checked/Unchecked-Trenner
-**Sync Rate-Limiting** - Globaler 30s Cooldown zwischen Auto-Syncs
-**Detekt: 0 Issues** - Alle 12 Findings behoben
---
@@ -110,7 +173,6 @@
### 🎨 UI Features
- **Widget** - Schnellzugriff vom Homescreen
- **Kategorien/Tags** - Notizen organisieren
- **Suche** - Volltextsuche in Notizen

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`
- **Smart sizing** - Small notes (short text, few checklist items) displayed compactly
- **Layout toggle** - Switch between List and Grid view in settings
- **Adaptive columns** - 2-3 columns based on screen size
- **120 FPS optimized** - Lazy loading for smooth scrolling with many notes
- **Pinterest-style staggered grid** - Gapless layout with dynamic preview lines
- **Layout toggle** - Switch between list and grid in settings
- **Adaptive columns** - 2-3 columns based on screen size
### 🔧 Server Folder Check
### 📡 Sync Improvements
- **WebDAV folder check** - Checks if folder exists and is writable on server
- **Better error messages** - Helpful hints for server problems
- **Connection test improvement** - Checks read/write permissions
- **WiFi-only sync toggle** - Sync only when connected to WiFi
- **VPN support** - Sync works correctly through VPN tunnels
- **Self-signed SSL** - Documentation and support for self-signed certificates
-**Server change detection** - All notes reset to PENDING when server URL changes
### 🔧 Technical Improvements
---
- **Code refactoring** - Split LargeClass components (WebDavSyncService, SettingsActivity)
- **Improved progress dialogs** - Material Design 3 compliant
## v1.7.1 - Android 9 Fix & VPN ✅
> **Status:** Released 🎉 (February 2026)
-**Android 9 crash fix** - Implemented `getForegroundInfo()` for WorkManager on API 28
-**VPN compatibility** - WiFi socket binding detects Wireguard VPN interfaces
-**SafeSardineWrapper** - Proper HTTP connection cleanup
---
## v1.7.2 - Timestamp & Deletion Fixes ✅
> **Status:** Released 🎉 (February 2026)
-**Server mtime as source of truth** - Fixes external editor timestamp issues
-**Deletion tracker mutex** - Thread-safe batch deletes
-**ISO8601 timezone parsing** - Multi-format support
-**E-Tag batch caching** - Performance improvement
-**Memory leak prevention** - SafeSardineWrapper with Closeable
---
## v1.8.0 - Widgets, Sorting & Advanced Sync ✅
> **Status:** Released 🎉 (February 2026)
### 📌 Homescreen Widgets
-**Full Jetpack Glance framework** - 5 responsive size classes
-**Interactive checklists** - Checkboxes that sync to server
-**Material You colors** - Dynamic colors with configurable opacity
-**Lock toggle** - Prevent accidental edits
-**Configuration activity** - Note selection and settings
### 📊 Sorting
-**Note sorting** - By title, date modified, date created, type
-**Checklist sorting** - Manual, alphabetical, unchecked first, checked last
-**Visual separators** - Between unchecked/checked groups
-**Drag across boundaries** - Auto-toggle state on cross-boundary drag
### 🔄 Sync Improvements
-**Parallel downloads** - Up to 5 simultaneous (configurable)
-**Server deletion detection** - Detects notes deleted on other clients
-**Live sync progress** - Phase indicators with counters
-**Sync status legend** - Help dialog explaining all sync icons
### ✨ UX
-**Post-update changelog** - Shows localized changelog on first launch after update
-**Grid as default** - New installations default to grid view
-**Toast → Banner migration** - Unified notification system
---
## v1.8.1 - Bugfix & Polish ✅
> **Status:** Released 🎉 (February 2026)
-**Checklist sort persistence** - Sort option correctly restored when reopening
-**Widget scroll fix** - Scroll works on standard 3×2 widget size
-**Widget checklist sorting** - Widgets apply saved sort option
-**Drag cross-boundary** - Drag & drop across checked/unchecked separator
-**Sync rate-limiting** - Global 30s cooldown between auto-syncs
-**Detekt: 0 issues** - All 12 findings resolved
---
@@ -110,7 +173,6 @@
### 🎨 UI Features
- **Widget** - Quick access from homescreen
- **Categories/Tags** - Organize notes
- **Search** - Full-text search in notes

View File

@@ -5,34 +5,50 @@ Diese Verzeichnisstruktur enthält alle Metadaten für die F-Droid-Veröffentlic
## Struktur
```
fastlane/metadata/android/de-DE/
├── title.txt # App-Name (max 50 Zeichen)
├── short_description.txt # Kurzbeschreibung (max 80 Zeichen)
├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen)
├── changelogs/
── 1.txt # Changelog für Version 1
└── images/
└── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit)
── 1.png # Hauptansicht (Notizliste)
├── 2.png # Notiz-Editor
├── 3.png # Settings
└── 4.png # Empty State
fastlane/metadata/android/
├── de-DE/ # Deutsche Lokalisierung (primär)
│ ├── title.txt # App-Name (max 50 Zeichen)
│ ├── short_description.txt # Kurzbeschreibung (max 80 Zeichen)
│ ├── full_description.txt # Vollständige Beschreibung (max 4000 Zeichen)
── changelogs/
│ │ ├── 1.txt ... 21.txt # Changelogs pro versionCode (max 500 Zeichen!)
└── images/
── phoneScreenshots/ # Screenshots (PNG/JPG, 320-3840px breit)
├── 1.png ... 5.png
└── en-US/ # Englische Lokalisierung
├── title.txt
├── short_description.txt
├── full_description.txt
├── changelogs/
│ ├── 1.txt ... 21.txt
└── images/
└── phoneScreenshots/
```
## Wichtige Limits
| Feld | Max. Länge | Hinweis |
|------|-----------|---------|
| `title.txt` | 50 Zeichen | App-Name |
| `short_description.txt` | 80 Zeichen | Kurzbeschreibung |
| `full_description.txt` | 4000 Zeichen | Vollständige Beschreibung |
| `changelogs/*.txt` | **500 Bytes** | Pro versionCode, **Bytes nicht Zeichen!** |
> **Achtung:** Changelogs werden in **Bytes** gemessen! UTF-8 Umlaute (ä, ö, ü) zählen als 2 Bytes.
## Screenshots erstellen
Verwende einen Android Emulator oder physisches Gerät mit:
Verwende ein physisches Gerät oder Emulator mit:
- Material You Theme aktiviert
- Deutsche Sprache
- Deutsche/Englische Sprache je nach Locale
- Screenshots in hoher Auflösung (1080x2400 empfohlen)
### Screenshot-Reihenfolge:
1. **Notizliste** - Mit mehreren Beispiel-Notizen, Sync-Status sichtbar
2. **Editor** - Zeige eine bearbeitete Notiz mit Titel und Inhalt
3. **Settings** - Server-Konfiguration mit erfolgreichem Server-Status
4. **Empty State** - Schöne leere Ansicht mit Material 3 Card
## F-Droid Build-Konfiguration
Die App verwendet den `fdroid` Build-Flavor ohne proprietäre Dependencies.
Siehe `build.gradle.kts` für Details.
Siehe `android/app/build.gradle.kts` für Details.
## Aktuelle Version
- **versionName:** 1.8.1
- **versionCode:** 21

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
- Stabilere Fehlerbehandlung bei Verbindungsproblemen
- 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
Kritische Fehlerbehebung
• Server-Wiederherstellung findet jetzt ALLE Notizen (Root + /notes/)
• User die von v1.2.0 upgraden verlieren keine Daten mehr
• Alte Notizen aus Root-Ordner werden beim Restore gefunden
• Server-Restore findet jetzt ALLE Notizen (Root + /notes/)
• Upgrade von v1.2.0 ohne Datenverlust
• Alte Notizen aus Root-Ordner werden gefunden
Technische Details
• Dual-Mode Download nur bei Server-Restore aktiv
• Normale Syncs bleiben schnell (scannen nur /notes/)
• Normale Syncs bleiben schnell (nur /notes/)
• Automatische Deduplication verhindert Duplikate
• Sanfte Migration: Neue Uploads gehen in /notes/, alte bleiben lesbar
• Sanfte Migration: Uploads /notes/, alte bleiben lesbar

View File

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

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

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