diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index 6148bd3..38d7540 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -8,6 +8,135 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.8.0] - 2026-02-10 + +### 🎉 Major: Widgets, Sortierung & Erweiterte Sync-Features + +Komplettes Widget-System mit interaktiven Checklisten, Notiz-Sortierung und umfangreiche Sync-Verbesserungen! + +### 🆕 Homescreen-Widgets + +**Vollständiges Jetpack Glance Widget-Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f)) +- 5 responsive Größenklassen (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL) +- Interaktive Checklist-Checkboxen die sofort zum Server synchronisieren +- Material You Dynamic Colors mit konfigurierbarer Hintergrund-Transparenz (0-100%) +- Widget-Sperre-Toggle zum Verhindern versehentlicher Änderungen +- Read-Only-Modus mit permanenter Options-Leiste für gesperrte Widgets +- Widget-Konfigurations-Activity mit Notiz-Auswahl und Einstellungen +- Auto-Refresh nach Sync-Abschluss +- Tippen auf Inhalt öffnet Editor (entsperrt) oder zeigt Optionen (gesperrt) +- Vollständige Resource-Cleanup-Fixes für Connection Leaks + +**Widget State Management:** +- NoteWidgetState Keys für pro-Instanz-Persistierung via DataStore +- Fünf Top-Level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config) +- Type-Safe Parameter-Übergabe mit NoteWidgetActionKeys + +### 📊 Notiz- & Checklisten-Sortierung + +**Notiz-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b)) +- Sortieren nach: Aktualisiert (neueste/älteste), Erstellt, Titel (A-Z/Z-A), Typ +- Persistente Sortierungs-Präferenzen (gespeichert in SharedPreferences) +- Sortierungs-Dialog im Hauptbildschirm mit Richtungs-Toggle +- Kombinierte sortedNotes StateFlow im MainViewModel + +**Checklisten-Sortierung** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7)) +- Sortieren nach: Manual, Alphabetisch, Offen zuerst, Erledigt zuletzt +- Visueller Separator zwischen offenen/erledigten Items mit Anzahl-Anzeige +- Auto-Sort bei Item-Toggle und Neuordnung +- Drag nur innerhalb gleicher Gruppe (offen/erledigt) +- Sanfte Fade/Slide-Animationen für Item-Übergänge +- Unit-getestet mit 9 Testfällen für Sortierungs-Logik-Validierung + +### 🔄 Sync-Verbesserungen + +**Server-Löschungs-Erkennung** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e)) +- Neuer `DELETED_ON_SERVER` Sync-Status für Multi-Device-Szenarien +- Erkennt wenn Notizen auf anderen Clients gelöscht wurden +- Zero Performance-Impact (nutzt existierende PROPFIND-Daten) +- Löschungs-Anzahl im Sync-Banner: "3 synchronisiert · 2 auf Server gelöscht" +- Bearbeitete gelöschte Notizen werden automatisch zum Server hochgeladen (Status → PENDING) + +**Sync-Status-Legende** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc)) +- Hilfe-Button (?) in Hauptbildschirm TopAppBar +- Dialog erklärt alle 5 Sync-Status-Icons mit Beschreibungen +- Nur sichtbar wenn Sync konfiguriert ist + +**Live-Sync-Fortschritts-UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a)) +- Echtzeit-Phasen-Indikatoren: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN +- Upload-Fortschritt zeigt x/y Counter (bekannte Gesamtzahl) +- Download-Fortschritt zeigt Anzahl (unbekannte Gesamtzahl) +- Einheitliches SyncProgressBanner (ersetzt Dual-System) +- Auto-Hide: COMPLETED (2s), ERROR (4s) +- Keine irreführenden Counter wenn nichts zu synchronisieren ist +- Stiller Auto-Sync bleibt still, Fehler werden immer angezeigt + +**Parallele Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf)) +- Konfigurierbare gleichzeitige Downloads (Standard: 3 simultan) +- Kotlin Coroutines async/awaitAll Pattern +- Individuelle Download-Timeout-Behandlung +- Graceful sequentieller Fallback bei gleichzeitigen Fehlern +- Optimierte Netzwerk-Auslastung für schnelleren Sync + +### ✨ UX-Verbesserungen + +**Checklisten-Verbesserungen:** +- Überlauf-Verlauf für lange Text-Items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93)) +- Auto-Expand bei Fokus, Collapse auf 5 Zeilen bei Fokus-Verlust +- Drag & Drop Flackern-Fix mit Straddle-Target-Center-Erkennung ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705)) +- Adjacency-Filter verhindert Item-Sprünge bei schnellem Drag +- Race-Condition-Fix für Scroll + Move-Operationen + +**Einstellungs-UI-Polish:** +- Sanfter Sprachwechsel ohne Activity-Recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd)) +- Raster-Ansicht als Standard für Neu-Installationen ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446)) +- Sync-Einstellungen umstrukturiert in klare Sektionen: Auslöser & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0)) +- Changelog-Link zum About-Screen hinzugefügt ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff)) + +**Post-Update Changelog-Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0)) +- Zeigt lokalisierten Changelog beim ersten Start nach Update +- Material 3 ModalBottomSheet mit Slide-up-Animation +- Lädt F-Droid Changelogs via Assets (Single Source of Truth) +- Einmalige Anzeige pro versionCode (gespeichert in SharedPreferences) +- Klickbarer GitHub-Link für vollständigen Changelog +- Durch Button oder Swipe-Geste schließbar +- Test-Modus in Debug-Einstellungen mit Reset-Option + +**Backup-Einstellungs-Verbesserungen** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed)) +- Neue BackupProgressCard mit LinearProgressIndicator +- 3-Phasen-Status-System: In Progress → Abschluss → Löschen +- Erfolgs-Status für 2s angezeigt, Fehler für 3s +- Redundante Toast-Nachrichten entfernt +- Buttons bleiben sichtbar und deaktiviert während Operationen +- Exception-Logging für besseres Error-Tracking + +### 🐛 Fehlerbehebungen + +**Widget-Text-Anzeige** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d)) +- Text-Notizen zeigen nicht mehr nur 3 Zeilen in Widgets +- Von Absatz-basiert zu Zeilen-basiertem Rendering geändert +- LazyColumn scrollt jetzt korrekt durch gesamten Inhalt +- Leere Zeilen als 8dp Spacer beibehalten +- Vorschau-Limits erhöht: compact 100→120, full 200→300 Zeichen + +### 🔧 Code-Qualität + +**Detekt-Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63)) +- Alle 22 Detekt-Warnungen behoben +- 7 ungenutzte Imports entfernt +- Konstanten für 5 Magic Numbers definiert +- State-Reads mit derivedStateOf optimiert +- Build: 0 Lint-Fehler + 0 Detekt-Warnungen + +### 📚 Dokumentation + +- Vollständige Implementierungs-Pläne für alle 23 v1.8.0 Features +- Widget-System-Architektur und State-Management-Docs +- Sortierungs-Logik Unit-Tests mit Edge-Case-Coverage +- F-Droid Changelogs (Englisch + Deutsch) + +--- + ## [1.7.2] - 2026-02-04 ### 🐛 Kritische Fehlerbehebungen diff --git a/CHANGELOG.md b/CHANGELOG.md index 758f009..22b99bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,135 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.8.0] - 2026-02-10 + +### 🎉 Major: Widgets, Sorting & Advanced Sync + +Complete widget system with interactive checklists, note sorting, and major sync improvements! + +### 🆕 Homescreen Widgets + +**Full Jetpack Glance Widget Framework** ([539987f](https://github.com/inventory69/simple-notes-sync/commit/539987f)) +- 5 responsive size classes (SMALL, NARROW_MED, NARROW_TALL, WIDE_MED, WIDE_TALL) +- Interactive checklist checkboxes that sync immediately to server +- Material You dynamic colors with configurable background opacity (0-100%) +- Lock widget toggle to prevent accidental edits +- Read-only mode with permanent options bar for locked widgets +- Widget configuration activity with note selection and settings +- Auto-refresh after sync completion +- Tap content to open editor (unlocked) or show options (locked) +- Complete resource cleanup fixes for connection leaks + +**Widget State Management:** +- NoteWidgetState keys for per-instance persistence via DataStore +- Five top-level ActionCallbacks (Toggle Checkbox, Lock, Options, Refresh, Config) +- Type-safe parameter passing with NoteWidgetActionKeys + +### 📊 Note & Checklist Sorting + +**Note Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b)) +- Sort by: Updated (newest/oldest), Created, Title (A-Z/Z-A), Type +- Persistent sort preferences (saved in SharedPreferences) +- Sort dialog in main screen with direction toggle +- Combined sortedNotes StateFlow in MainViewModel + +**Checklist Sorting** ([96c819b](https://github.com/inventory69/simple-notes-sync/commit/96c819b), [900dad7](https://github.com/inventory69/simple-notes-sync/commit/900dad7)) +- Sort by: Manual, Alphabetical, Unchecked First, Checked Last +- Visual separator between unchecked/checked items with count display +- Auto-sort on item toggle and reordering +- Drag-only within same group (unchecked/checked) +- Smooth fade/slide animations for item transitions +- Unit tested with 9 test cases for sorting logic validation + +### 🔄 Sync Improvements + +**Server Deletion Detection** ([40d7c83](https://github.com/inventory69/simple-notes-sync/commit/40d7c83), [bf7a74e](https://github.com/inventory69/simple-notes-sync/commit/bf7a74e)) +- New `DELETED_ON_SERVER` sync status for multi-device scenarios +- Detects when notes are deleted on other clients +- Zero performance impact (uses existing PROPFIND data) +- Deletion count shown in sync banner: "3 synced · 2 deleted on server" +- Edited deleted notes automatically re-upload to server (status → PENDING) + +**Sync Status Legend** ([07607fc](https://github.com/inventory69/simple-notes-sync/commit/07607fc)) +- Help button (?) in main screen TopAppBar +- Dialog explaining all 5 sync status icons with descriptions +- Only visible when sync is configured + +**Live Sync Progress UI** ([df37d2a](https://github.com/inventory69/simple-notes-sync/commit/df37d2a)) +- Real-time phase indicators: PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN +- Upload progress shows x/y counter (known total) +- Download progress shows count (unknown total) +- Single unified SyncProgressBanner (replaces dual system) +- Auto-hide: COMPLETED (2s), ERROR (4s) +- No misleading counters when nothing to sync +- Silent auto-sync stays silent, errors always shown + +**Parallel Downloads** ([bdfc0bf](https://github.com/inventory69/simple-notes-sync/commit/bdfc0bf)) +- Configurable concurrent downloads (default: 3 simultaneous) +- Kotlin coroutines async/awaitAll pattern +- Individual download timeout handling +- Graceful sequential fallback on concurrent errors +- Optimized network utilization for faster sync + +### ✨ UX Improvements + +**Checklist Enhancements:** +- Overflow gradient for long text items ([3462f93](https://github.com/inventory69/simple-notes-sync/commit/3462f93)) +- Auto-expand on focus, collapse to 5 lines when unfocused +- Drag & Drop flicker fix with straddle-target-center detection ([538a705](https://github.com/inventory69/simple-notes-sync/commit/538a705)) +- Adjacency filter prevents item jumps during fast drag +- Race-condition fix for scroll + move operations + +**Settings UI Polish:** +- Smooth language switching without activity recreate ([881c0fd](https://github.com/inventory69/simple-notes-sync/commit/881c0fd)) +- Grid view as default for new installations ([6858446](https://github.com/inventory69/simple-notes-sync/commit/6858446)) +- Sync settings restructured into clear sections: Triggers & Performance ([eaac5a0](https://github.com/inventory69/simple-notes-sync/commit/eaac5a0)) +- Changelog link added to About screen ([49810ff](https://github.com/inventory69/simple-notes-sync/commit/49810ff)) + +**Post-Update Changelog Dialog** ([661d9e0](https://github.com/inventory69/simple-notes-sync/commit/661d9e0)) +- Shows localized changelog on first launch after update +- Material 3 ModalBottomSheet with slide-up animation +- Loads F-Droid changelogs via assets (single source of truth) +- One-time display per versionCode (stored in SharedPreferences) +- Clickable GitHub link for full changelog +- Dismissable via button or swipe gesture +- Test mode in Debug Settings with reset option + +**Backup Settings Improvements** ([3e946ed](https://github.com/inventory69/simple-notes-sync/commit/3e946ed)) +- New BackupProgressCard with LinearProgressIndicator +- 3-phase status system: In Progress → Completion → Clear +- Success status shown for 2s, errors for 3s +- Removed redundant toast messages +- Buttons stay visible and disabled during operations +- Exception logging for better error tracking + +### 🐛 Bug Fixes + +**Widget Text Display** ([d045d4d](https://github.com/inventory69/simple-notes-sync/commit/d045d4d)) +- Fixed text notes showing only 3 lines in widgets +- Changed from paragraph-based to line-based rendering +- LazyColumn now properly scrolls through all content +- Empty lines preserved as 8dp spacers +- Preview limits increased: compact 100→120, full 200→300 chars + +### 🔧 Code Quality + +**Detekt Cleanup** ([1da1a63](https://github.com/inventory69/simple-notes-sync/commit/1da1a63)) +- Resolved all 22 Detekt warnings +- Removed 7 unused imports +- Defined constants for 5 magic numbers +- Optimized state reads with derivedStateOf +- Build: 0 Lint errors + 0 Detekt warnings + +### 📚 Documentation + +- Complete implementation plans for all 23 v1.8.0 features +- Widget system architecture and state management docs +- Sorting logic unit tests with edge case coverage +- F-Droid changelogs (English + German) + +--- + ## [1.7.2] - 2026-02-04 ### 🐛 Critical Bug Fixes diff --git a/android/.gitignore b/android/.gitignore index bfe4c24..0b21239 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -18,3 +18,4 @@ local.properties key.properties *.jks *.keystore +/app/src/main/assets/changelogs/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6ce807d..117f9d4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 19 // 🔧 v1.7.2: Critical Bugfixes (Timestamp Sync, SyncStatus, etc.) - versionName = "1.7.2" // 🔧 v1.7.2: Critical Bugfixes + versionCode = 20 // 🎉 v1.8.0: Widgets, Sorting, UI Polish, Post-Update Changelog + versionName = "1.8.0" // 🎉 v1.8.0: Major Feature Release testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -162,6 +162,12 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) debugImplementation(libs.androidx.compose.ui.tooling) + // ═══════════════════════════════════════════════════════════════════════ + // 🆕 v1.8.0: Homescreen Widgets + // ═══════════════════════════════════════════════════════════════════════ + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") + // Testing (bleiben so) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) @@ -193,4 +199,34 @@ detekt { // Parallel-Verarbeitung für schnellere Checks parallel = true +} + +// 📋 v1.8.0: Copy F-Droid changelogs to assets for post-update dialog +// Single source of truth: F-Droid changelogs are reused in the app +tasks.register("copyChangelogsToAssets") { + description = "Copies F-Droid changelogs to app assets for post-update dialog" + + from("$rootDir/../fastlane/metadata/android") { + include("*/changelogs/*.txt") + } + + into("$projectDir/src/main/assets/changelogs") + + // Preserve directory structure: en-US/20.txt, de-DE/20.txt + eachFile { + val parts = relativePath.segments + if (parts.size >= 3) { + // parts[0] = locale (en-US, de-DE) + // parts[1] = "changelogs" + // parts[2] = version file (20.txt) + relativePath = RelativePath(true, parts[0], parts[2]) + } + } + + includeEmptyDirs = false +} + +// Run before preBuild to ensure changelogs are available +tasks.named("preBuild") { + dependsOn("copyChangelogsToAssets") } \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 87335da..4e87efa 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -59,8 +59,25 @@ -keep class * implements com.google.gson.JsonSerializer -keep class * implements com.google.gson.JsonDeserializer -# Keep your app's data classes --keep class dev.dettmer.simplenotes.** { *; } +# ═══════════════════════════════════════════════════════════════════════ +# App-specific rules: Only keep what Gson/reflection needs +# ═══════════════════════════════════════════════════════════════════════ + +# Gson data models (serialized/deserialized via reflection) +-keep class dev.dettmer.simplenotes.models.Note { *; } +-keep class dev.dettmer.simplenotes.models.Note$NoteRaw { *; } +-keep class dev.dettmer.simplenotes.models.ChecklistItem { *; } +-keep class dev.dettmer.simplenotes.models.DeletionRecord { *; } +-keep class dev.dettmer.simplenotes.models.DeletionTracker { *; } +-keep class dev.dettmer.simplenotes.backup.BackupData { *; } +-keep class dev.dettmer.simplenotes.backup.BackupResult { *; } + +# Keep enum values (used in serialization and widget state) +-keepclassmembers enum dev.dettmer.simplenotes.** { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); +} # v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions # This class only exists on API 35+ but Compose handles the fallback gracefully diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c348297..dfa201d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -69,8 +69,10 @@ android:parentActivityName=".ui.main.ComposeMainActivity" /> + @@ -102,6 +104,25 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt index 71b04b6..1fa509d 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -2,6 +2,7 @@ package dev.dettmer.simplenotes +import android.annotation.SuppressLint import android.app.ProgressDialog import android.content.Context import android.content.Intent @@ -186,10 +187,12 @@ class SettingsActivity : AppCompatActivity() { } // Set URL with protocol prefix in the text field + @Suppress("SetTextI18n") // Technical URL, not UI text editTextServerUrl.setText("$protocol://$hostPath") } else { // Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix radioHttp.isChecked = true + @Suppress("SetTextI18n") // Technical URL, not UI text editTextServerUrl.setText("http://") } @@ -252,6 +255,7 @@ class SettingsActivity : AppCompatActivity() { } // Set new URL with correct protocol + @Suppress("SetTextI18n") // Technical URL, not UI text editTextServerUrl.setText("$newProtocol://$hostPath") // Move cursor to end @@ -379,7 +383,7 @@ class SettingsActivity : AppCompatActivity() { val versionName = BuildConfig.VERSION_NAME val versionCode = BuildConfig.VERSION_CODE - textViewAppVersion.text = "Version $versionName ($versionCode)" + textViewAppVersion.text = getString(R.string.about_version, versionName, versionCode) } catch (e: Exception) { Logger.e(TAG, "Failed to load version info", e) textViewAppVersion.text = getString(R.string.version_not_available) @@ -644,7 +648,7 @@ class SettingsActivity : AppCompatActivity() { val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) if (serverUrl.isNullOrEmpty()) { - textViewServerStatus.text = "❌ Nicht konfiguriert" + textViewServerStatus.text = getString(R.string.server_status_not_configured) textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark)) return } @@ -669,10 +673,10 @@ class SettingsActivity : AppCompatActivity() { } if (isReachable) { - textViewServerStatus.text = "✅ Erreichbar" + textViewServerStatus.text = getString(R.string.server_status_reachable) textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark)) } else { - textViewServerStatus.text = "❌ Nicht erreichbar" + textViewServerStatus.text = getString(R.string.server_status_unreachable) textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark)) } } @@ -818,6 +822,12 @@ class SettingsActivity : AppCompatActivity() { .show() } + /** + * Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds. + * For Play Store builds, this would need to be changed to + * ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly). + */ + @SuppressLint("BatteryLife") private fun openBatteryOptimizationSettings() { try { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) @@ -947,6 +957,7 @@ class SettingsActivity : AppCompatActivity() { } // Info Text + @Suppress("SetTextI18n") // Programmatically generated dialog text val infoText = android.widget.TextView(this).apply { text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:" textSize = 16f @@ -955,7 +966,7 @@ class SettingsActivity : AppCompatActivity() { // Hinweis Text val hintText = android.widget.TextView(this).apply { - text = "\nℹ️ Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt." + text = getString(R.string.backup_restore_info) textSize = 14f setTypeface(null, android.graphics.Typeface.ITALIC) setPadding(0, 20, 0, 0) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt index 6161d61..14efaad 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt @@ -89,6 +89,7 @@ class NotesAdapter( SyncStatus.PENDING -> android.R.drawable.ic_popup_sync SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save + SyncStatus.DELETED_ON_SERVER -> android.R.drawable.ic_menu_delete // 🆕 v1.8.0 } imageViewSyncStatus.setImageResource(syncIcon) imageViewSyncStatus.visibility = View.VISIBLE diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/ChecklistSortOption.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/ChecklistSortOption.kt new file mode 100644 index 0000000..f39211e --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/ChecklistSortOption.kt @@ -0,0 +1,21 @@ +package dev.dettmer.simplenotes.models + +/** + * 🆕 v1.8.0: Sortieroptionen für Checklist-Items im Editor + */ +enum class ChecklistSortOption { + /** Manuelle Reihenfolge (Drag & Drop) — kein Re-Sort */ + MANUAL, + + /** Alphabetisch A→Z */ + ALPHABETICAL_ASC, + + /** Alphabetisch Z→A */ + ALPHABETICAL_DESC, + + /** Unchecked zuerst, dann Checked */ + UNCHECKED_FIRST, + + /** Checked zuerst, dann Unchecked */ + CHECKED_FIRST +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/SortDirection.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/SortDirection.kt new file mode 100644 index 0000000..542a5eb --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/SortDirection.kt @@ -0,0 +1,20 @@ +package dev.dettmer.simplenotes.models + +/** + * 🆕 v1.8.0: Sortierrichtung + */ +enum class SortDirection(val prefsValue: String) { + ASCENDING("asc"), + DESCENDING("desc"); + + fun toggle(): SortDirection = when (this) { + ASCENDING -> DESCENDING + DESCENDING -> ASCENDING + } + + companion object { + fun fromPrefsValue(value: String): SortDirection { + return entries.find { it.prefsValue == value } ?: DESCENDING + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/SortOption.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/SortOption.kt new file mode 100644 index 0000000..47b1a29 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/SortOption.kt @@ -0,0 +1,24 @@ +package dev.dettmer.simplenotes.models + +/** + * 🆕 v1.8.0: Sortieroptionen für die Notizliste + */ +enum class SortOption(val prefsValue: String) { + /** Zuletzt bearbeitete zuerst (Default) */ + UPDATED_AT("updatedAt"), + + /** Zuletzt erstellte zuerst */ + CREATED_AT("createdAt"), + + /** Alphabetisch nach Titel */ + TITLE("title"), + + /** Nach Notiz-Typ (Text / Checkliste) */ + NOTE_TYPE("noteType"); + + companion object { + fun fromPrefsValue(value: String): SortOption { + return entries.find { it.prefsValue == value } ?: UPDATED_AT + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt index c1aea44..042d026 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt @@ -1,8 +1,15 @@ package dev.dettmer.simplenotes.models +/** + * Sync-Status einer Notiz + * + * v1.4.0: Initial (LOCAL_ONLY, SYNCED, PENDING, CONFLICT) + * v1.8.0: DELETED_ON_SERVER hinzugefügt + */ enum class SyncStatus { - LOCAL_ONLY, // Noch nie gesynct - SYNCED, // Erfolgreich gesynct - PENDING, // Wartet auf Sync - CONFLICT // Konflikt erkannt + LOCAL_ONLY, // Noch nie gesynct + SYNCED, // Erfolgreich gesynct + PENDING, // Wartet auf Sync + CONFLICT, // Konflikt erkannt + DELETED_ON_SERVER // 🆕 v1.8.0: Server hat gelöscht, lokal noch vorhanden } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt index 75dec82..027b64f 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt @@ -35,11 +35,16 @@ class NotesStorage(private val context: Context) { } } + /** + * Lädt alle Notizen aus dem lokalen Speicher. + * + * 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt, + * damit der User die Sortierung konfigurieren kann. + */ fun loadAllNotes(): List { return notesDir.listFiles() ?.filter { it.extension == "json" } ?.mapNotNull { Note.fromJson(it.readText()) } - ?.sortedByDescending { it.updatedAt } ?: emptyList() } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt index ec7cb91..d9e7305 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt @@ -5,11 +5,15 @@ import com.thegrizzlylabs.sardineandroid.Sardine import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import dev.dettmer.simplenotes.utils.Logger import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.io.Closeable import java.io.InputStream +private const val HTTP_METHOD_NOT_ALLOWED = 405 + /** * 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert * 🔧 v1.7.2 (IMPL_003): Implementiert Closeable für explizites Resource-Management @@ -108,6 +112,73 @@ class SafeSardineWrapper private constructor( Logger.d(TAG, "list($url, depth=$depth)") return delegate.list(url, depth) } + + /** + * ✅ Sichere put()-Implementation mit Response Cleanup + * + * Im Gegensatz zu OkHttpSardine.put() wird hier der Response-Body garantiert geschlossen. + * Verhindert "connection leaked" Warnungen. + */ + override fun put(url: String, data: ByteArray, contentType: String?) { + val mediaType = contentType?.toMediaTypeOrNull() + val body = data.toRequestBody(mediaType) + + val request = Request.Builder() + .url(url) + .put(body) + .header("Authorization", authHeader) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw java.io.IOException("PUT failed: ${response.code} ${response.message}") + } + Logger.d(TAG, "put($url) → ${response.code}") + } + } + + /** + * ✅ Sichere delete()-Implementation mit Response Cleanup + * + * Im Gegensatz zu OkHttpSardine.delete() wird hier der Response-Body garantiert geschlossen. + * Verhindert "connection leaked" Warnungen. + */ + override fun delete(url: String) { + val request = Request.Builder() + .url(url) + .delete() + .header("Authorization", authHeader) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw java.io.IOException("DELETE failed: ${response.code} ${response.message}") + } + Logger.d(TAG, "delete($url) → ${response.code}") + } + } + + /** + * ✅ Sichere createDirectory()-Implementation mit Response Cleanup + * + * Im Gegensatz zu OkHttpSardine.createDirectory() wird hier der Response-Body garantiert geschlossen. + * Verhindert "connection leaked" Warnungen. + * 405 (Method Not Allowed) wird toleriert da dies bedeutet, dass der Ordner bereits existiert. + */ + override fun createDirectory(url: String) { + val request = Request.Builder() + .url(url) + .method("MKCOL", null) + .header("Authorization", authHeader) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful && response.code != HTTP_METHOD_NOT_ALLOWED) { // 405 = already exists + throw java.io.IOException("MKCOL failed: ${response.code} ${response.message}") + } + Logger.d(TAG, "createDirectory($url) → ${response.code}") + } + } /** * 🆕 v1.7.2 (IMPL_003): Schließt alle offenen Verbindungen diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt new file mode 100644 index 0000000..f2f25c0 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt @@ -0,0 +1,99 @@ +package dev.dettmer.simplenotes.sync + +/** + * 🆕 v1.8.0: Detaillierter Sync-Fortschritt für UI + * + * Einziges Banner-System für den gesamten Sync-Lebenszyklus: + * - PREPARING: Sofort beim Klick, bleibt während Vor-Checks und Server-Prüfung + * - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen + * - COMPLETED / ERROR: Ergebnis mit Nachricht + Auto-Hide + * + * Ersetzt das alte duale Banner-System (SyncStatusBanner + SyncProgressBanner) + */ +data class SyncProgress( + val phase: SyncPhase = SyncPhase.IDLE, + val current: Int = 0, + val total: Int = 0, + val currentFileName: String? = null, + val resultMessage: String? = null, + val silent: Boolean = false, + val startTime: Long = System.currentTimeMillis() +) { + /** + * Fortschritt als Float zwischen 0.0 und 1.0 + */ + val progress: Float + get() = if (total > 0) current.toFloat() / total else 0f + + /** + * Fortschritt als Prozent (0-100) + */ + val percentComplete: Int + get() = (progress * 100).toInt() + + /** + * Vergangene Zeit seit Start in Millisekunden + */ + val elapsedMs: Long + get() = System.currentTimeMillis() - startTime + + /** + * Geschätzte verbleibende Zeit in Millisekunden + * Basiert auf durchschnittlicher Zeit pro Item + */ + val estimatedRemainingMs: Long? + get() { + if (current == 0 || total == 0) return null + val avgTimePerItem = elapsedMs / current + val remaining = total - current + return avgTimePerItem * remaining + } + + /** + * Ob das Banner sichtbar sein soll + * Silent syncs zeigen nie ein Banner + */ + val isVisible: Boolean + get() = !silent && phase != SyncPhase.IDLE + + /** + * Ob gerade ein aktiver Sync läuft (mit Spinner) + */ + val isActiveSync: Boolean + get() = phase in listOf( + SyncPhase.PREPARING, + SyncPhase.UPLOADING, + SyncPhase.DOWNLOADING, + SyncPhase.IMPORTING_MARKDOWN + ) + + companion object { + val IDLE = SyncProgress(phase = SyncPhase.IDLE) + } +} + +/** + * 🆕 v1.8.0: Sync-Phasen für detailliertes Progress-Tracking + */ +enum class SyncPhase { + /** Kein Sync aktiv */ + IDLE, + + /** Sync wurde gestartet, Vor-Checks laufen (hasUnsyncedChanges, isReachable, Server-Verzeichnis) */ + PREPARING, + + /** Lädt lokale Änderungen auf den Server hoch */ + UPLOADING, + + /** Lädt Server-Änderungen herunter */ + DOWNLOADING, + + /** Importiert Markdown-Dateien vom Server */ + IMPORTING_MARKDOWN, + + /** Sync erfolgreich abgeschlossen */ + COMPLETED, + + /** Sync mit Fehler abgebrochen */ + ERROR +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt index 3aa986a..dc711af 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt @@ -1,11 +1,18 @@ package dev.dettmer.simplenotes.sync +/** + * Ergebnis eines Sync-Vorgangs + * + * v1.7.0: Initial + * v1.8.0: deletedOnServerCount hinzugefügt + */ data class SyncResult( val isSuccess: Boolean, val syncedCount: Int = 0, val conflictCount: Int = 0, + val deletedOnServerCount: Int = 0, // 🆕 v1.8.0 val errorMessage: String? = null ) { - val hasConflicts: Boolean - get() = conflictCount > 0 + val hasConflicts: Boolean get() = conflictCount > 0 + val hasServerDeletions: Boolean get() = deletedOnServerCount > 0 // 🆕 v1.8.0 } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt index e43f513..ccdbd52 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt @@ -3,46 +3,53 @@ package dev.dettmer.simplenotes.sync import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import dev.dettmer.simplenotes.utils.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status + * 🆕 v1.8.0: Komplett überarbeitet - SyncProgress ist jetzt das einzige Banner-System * - * Verhindert doppelte Syncs und informiert die UI über den aktuellen Status. - * Thread-safe Singleton mit LiveData für UI-Reaktivität. + * SyncProgress (StateFlow) steuert den gesamten Sync-Lebenszyklus: + * PREPARING → [UPLOADING] → [DOWNLOADING] → [IMPORTING_MARKDOWN] → COMPLETED/ERROR → IDLE + * + * SyncStatus (LiveData) wird nur noch intern für Mutex/Silent-Tracking verwendet. */ object SyncStateManager { private const val TAG = "SyncStateManager" /** - * Mögliche Sync-Zustände + * Mögliche Sync-Zustände (intern für Mutex + PullToRefresh) */ enum class SyncState { - IDLE, // Kein Sync aktiv - SYNCING, // Sync läuft gerade (Banner sichtbar) - SYNCING_SILENT, // v1.5.0: Sync läuft im Hintergrund (kein Banner, z.B. onResume) - COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen) - ERROR // Sync fehlgeschlagen (kurz anzeigen) + IDLE, + SYNCING, + SYNCING_SILENT, + COMPLETED, + ERROR } /** - * Detaillierte Sync-Informationen für UI + * Interne Sync-Informationen (für Mutex-Management + Silent-Tracking) */ data class SyncStatus( val state: SyncState = SyncState.IDLE, val message: String? = null, - val source: String? = null, // "manual", "auto", "pullToRefresh", "background" - val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt + val source: String? = null, + val silent: Boolean = false, val timestamp: Long = System.currentTimeMillis() ) - // Private mutable LiveData + // Intern: Mutex + PullToRefresh State private val _syncStatus = MutableLiveData(SyncStatus()) - - // Public immutable LiveData für Observer val syncStatus: LiveData = _syncStatus - // Lock für Thread-Sicherheit + // 🆕 v1.8.0: Einziges Banner-System - SyncProgress + private val _syncProgress = MutableStateFlow(SyncProgress.IDLE) + val syncProgress: StateFlow = _syncProgress.asStateFlow() + private val lock = Any() /** @@ -56,54 +63,63 @@ object SyncStateManager { /** * Versucht einen Sync zu starten. - * @param source Quelle des Syncs (für Logging) - * @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync) - * @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft + * Bei silent=false: Setzt sofort PREPARING-Phase → Banner erscheint instant + * Bei silent=true: Setzt silent-Flag → kein Banner wird angezeigt */ fun tryStartSync(source: String, silent: Boolean = false): Boolean { synchronized(lock) { if (isSyncing) { - Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source") + Logger.d(TAG, "⚠️ Sync already in progress, rejecting from: $source") return false } val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)") + _syncStatus.postValue( SyncStatus( state = syncState, - message = "Synchronisiere...", source = source, - silent = silent // v1.5.0: Merkt sich ob silent für markCompleted() + silent = silent ) ) + + // 🆕 v1.8.0: Sofort PREPARING-Phase setzen (Banner erscheint instant) + _syncProgress.value = SyncProgress( + phase = SyncPhase.PREPARING, + silent = silent, + startTime = System.currentTimeMillis() + ) + return true } } /** * Markiert Sync als erfolgreich abgeschlossen - * v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner) + * Bei Silent-Sync: direkt auf IDLE (kein Banner) + * Bei normalem Sync: COMPLETED mit Nachricht (auto-hide durch UI) */ fun markCompleted(message: String? = null) { synchronized(lock) { val current = _syncStatus.value - val currentSource = current?.source val wasSilent = current?.silent == true + val currentSource = current?.source Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)") if (wasSilent) { - // v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen + // Silent-Sync: Direkt auf IDLE - kein Banner _syncStatus.postValue(SyncStatus()) + _syncProgress.value = SyncProgress.IDLE } else { - // Normaler Sync - COMPLETED State anzeigen + // Normaler Sync: COMPLETED mit Nachricht anzeigen _syncStatus.postValue( - SyncStatus( - state = SyncState.COMPLETED, - message = message, - source = currentSource - ) + SyncStatus(state = SyncState.COMPLETED, message = message, source = currentSource) + ) + _syncProgress.value = SyncProgress( + phase = SyncPhase.COMPLETED, + resultMessage = message ) } } @@ -111,38 +127,90 @@ object SyncStateManager { /** * Markiert Sync als fehlgeschlagen + * Bei Silent-Sync: Fehler trotzdem anzeigen (wichtig für User) */ fun markError(errorMessage: String?) { synchronized(lock) { - val currentSource = _syncStatus.value?.source + val current = _syncStatus.value + val wasSilent = current?.silent == true + val currentSource = current?.source + Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage") + _syncStatus.postValue( - SyncStatus( - state = SyncState.ERROR, - message = errorMessage, - source = currentSource - ) + SyncStatus(state = SyncState.ERROR, message = errorMessage, source = currentSource) + ) + + // Fehler immer anzeigen (auch bei Silent-Sync) + _syncProgress.value = SyncProgress( + phase = SyncPhase.ERROR, + resultMessage = errorMessage, + silent = false // Fehler nie silent ) } } /** - * Setzt Status zurück auf IDLE + * Setzt alles zurück auf IDLE */ fun reset() { synchronized(lock) { _syncStatus.postValue(SyncStatus()) + _syncProgress.value = SyncProgress.IDLE + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // 🆕 v1.8.0: Detailliertes Progress-Tracking (während syncNotes()) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Aktualisiert den detaillierten Sync-Fortschritt + * Behält silent-Flag und startTime der aktuellen Session bei + */ + fun updateProgress( + phase: SyncPhase, + current: Int = 0, + total: Int = 0, + currentFileName: String? = null + ) { + synchronized(lock) { + val existing = _syncProgress.value + _syncProgress.value = SyncProgress( + phase = phase, + current = current, + total = total, + currentFileName = currentFileName, + silent = existing.silent, + startTime = existing.startTime + ) } } /** - * Aktualisiert die Nachricht während des Syncs (z.B. Progress) + * Inkrementiert den Fortschritt um 1 + * Praktisch für Schleifen: nach jedem tatsächlichen Download */ - fun updateMessage(message: String) { + fun incrementProgress(currentFileName: String? = null) { synchronized(lock) { - val current = _syncStatus.value ?: return - if (current.state == SyncState.SYNCING) { - _syncStatus.postValue(current.copy(message = message)) + val current = _syncProgress.value + _syncProgress.value = current.copy( + current = current.current + 1, + currentFileName = currentFileName + ) + } + } + + /** + * Setzt Progress zurück auf IDLE (am Ende von syncNotes()) + * Wird NICHT für COMPLETED/ERROR verwendet - nur für Cleanup + */ + fun resetProgress() { + // Nicht zurücksetzen wenn COMPLETED/ERROR - die UI braucht den State noch für auto-hide + synchronized(lock) { + val current = _syncProgress.value + if (current.phase != SyncPhase.COMPLETED && current.phase != SyncPhase.ERROR) { + _syncProgress.value = SyncProgress.IDLE } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt index 43ee154..3863098 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt @@ -226,7 +226,21 @@ class SyncWorker( Logger.d(TAG, " Broadcasting sync completed...") } broadcastSyncCompleted(true, result.syncedCount) - + + // 🆕 v1.8.0: Alle Widgets aktualisieren nach Sync + try { + if (BuildConfig.DEBUG) { + Logger.d(TAG, " Updating widgets...") + } + val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(applicationContext) + val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java) + glanceIds.forEach { id -> + dev.dettmer.simplenotes.widget.NoteWidget().update(applicationContext, id) + } + } catch (e: Exception) { + Logger.w(TAG, "Failed to update widgets: ${e.message}") + } + if (BuildConfig.DEBUG) { Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS") Logger.d(TAG, "═══════════════════════════════════════") diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index 20efa08..eab9726 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -10,10 +10,14 @@ import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.storage.NotesStorage +import dev.dettmer.simplenotes.sync.parallel.DownloadTask +import dev.dettmer.simplenotes.sync.parallel.DownloadTaskResult +import dev.dettmer.simplenotes.sync.parallel.ParallelDownloader import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.SyncException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -597,6 +601,8 @@ class WebDavSyncService(private val context: Context) { Logger.d(TAG, "Thread: ${Thread.currentThread().name}") return@withContext try { + // 🆕 v1.8.0: Banner bleibt in PREPARING bis echte Arbeit (Upload/Download) anfällt + Logger.d(TAG, "📍 Step 1: Getting Sardine client") val sardine = try { @@ -640,11 +646,23 @@ class WebDavSyncService(private val context: Context) { // Ensure notes-md/ directory exists (for Markdown export) ensureMarkdownDirectoryExists(sardine, serverUrl) + // 🆕 v1.8.0: Phase 2 - Uploading (Phase wird nur bei echten Uploads gesetzt) Logger.d(TAG, "📍 Step 4: Uploading local notes") // Upload local notes try { Logger.d(TAG, "⬆️ Uploading local notes...") - val uploadedCount = uploadLocalNotes(sardine, serverUrl) + val uploadedCount = uploadLocalNotes( + sardine, + serverUrl, + onProgress = { current, total, noteTitle -> + SyncStateManager.updateProgress( + phase = SyncPhase.UPLOADING, + current = current, + total = total, + currentFileName = noteTitle + ) + } + ) syncedCount += uploadedCount Logger.d(TAG, "✅ Uploaded: $uploadedCount notes") } catch (e: Exception) { @@ -653,21 +671,35 @@ class WebDavSyncService(private val context: Context) { throw e } + // 🆕 v1.8.0: Phase 3 - Downloading (Phase wird nur bei echten Downloads gesetzt) Logger.d(TAG, "📍 Step 5: Downloading remote notes") // Download remote notes + var deletedOnServerCount = 0 // 🆕 v1.8.0 try { Logger.d(TAG, "⬇️ Downloading remote notes...") val downloadResult = downloadRemoteNotes( sardine, serverUrl, - includeRootFallback = true // ✅ v1.3.0: Enable for v1.2.0 compatibility + includeRootFallback = true, // ✅ v1.3.0: Enable for v1.2.0 compatibility + onProgress = { current, _, noteTitle -> + // 🆕 v1.8.0: Phase wird erst beim ersten echten Download gesetzt + // current = laufender Zähler (downloadedCount), kein Total → kein irreführender x/y Counter + SyncStateManager.updateProgress( + phase = SyncPhase.DOWNLOADING, + current = current, + total = 0, + currentFileName = noteTitle + ) + } ) syncedCount += downloadResult.downloadedCount conflictCount += downloadResult.conflictCount + deletedOnServerCount = downloadResult.deletedOnServerCount // 🆕 v1.8.0 Logger.d( TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, " + - "Conflicts: ${downloadResult.conflictCount}" + "Conflicts: ${downloadResult.conflictCount}, " + + "Deleted on server: ${downloadResult.deletedOnServerCount}" // 🆕 v1.8.0 ) } catch (e: Exception) { Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e) @@ -676,11 +708,15 @@ class WebDavSyncService(private val context: Context) { } Logger.d(TAG, "📍 Step 6: Auto-import Markdown (if enabled)") + // Auto-import Markdown files from server var markdownImportedCount = 0 try { val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) if (markdownAutoImportEnabled) { + // 🆕 v1.8.0: Phase nur setzen wenn Feature aktiv + SyncStateManager.updateProgress(phase = SyncPhase.IMPORTING_MARKDOWN) + Logger.d(TAG, "📥 Auto-importing Markdown files...") markdownImportedCount = importMarkdownFiles(sardine, serverUrl) Logger.d(TAG, "✅ Auto-imported: $markdownImportedCount Markdown files") @@ -701,6 +737,7 @@ class WebDavSyncService(private val context: Context) { } Logger.d(TAG, "📍 Step 7: Saving sync timestamp") + // Update last sync timestamp try { saveLastSyncTimestamp() @@ -724,12 +761,23 @@ class WebDavSyncService(private val context: Context) { if (markdownImportedCount > 0 && syncedCount > 0) { Logger.d(TAG, "📝 Including $markdownImportedCount Markdown file updates") } + if (deletedOnServerCount > 0) { // 🆕 v1.8.0 + Logger.d(TAG, "🗑️ Detected $deletedOnServerCount notes deleted on server") + } Logger.d(TAG, "═══════════════════════════════════════") + // 🆕 v1.8.0: Phase 6 - Completed + SyncStateManager.updateProgress( + phase = SyncPhase.COMPLETED, + current = effectiveSyncedCount, + total = effectiveSyncedCount + ) + SyncResult( isSuccess = true, syncedCount = effectiveSyncedCount, - conflictCount = conflictCount + conflictCount = conflictCount, + deletedOnServerCount = deletedOnServerCount // 🆕 v1.8.0 ) } catch (e: Exception) { @@ -741,6 +789,9 @@ class WebDavSyncService(private val context: Context) { e.printStackTrace() Logger.e(TAG, "═══════════════════════════════════════") + // 🆕 v1.8.0: Phase ERROR + SyncStateManager.updateProgress(phase = SyncPhase.ERROR) + SyncResult( isSuccess = false, errorMessage = when (e) { @@ -763,6 +814,8 @@ class WebDavSyncService(private val context: Context) { } finally { // ⚡ v1.3.1: Session-Caches leeren clearSessionCache() + // 🆕 v1.8.0: Reset progress state + SyncStateManager.resetProgress() // 🔒 v1.3.1: Sync-Mutex freigeben syncMutex.unlock() } @@ -770,11 +823,21 @@ class WebDavSyncService(private val context: Context) { @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") // Sync logic requires nested conditions for comprehensive error handling and state management - private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { + private fun uploadLocalNotes( + sardine: Sardine, + serverUrl: String, + onProgress: (current: Int, total: Int, noteTitle: String) -> Unit = { _, _, _ -> } // 🆕 v1.8.0 + ): Int { var uploadedCount = 0 val localNotes = storage.loadAllNotes() val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) + // 🆕 v1.8.0: Zähle zu uploadende Notizen für Progress + val pendingNotes = localNotes.filter { + it.syncStatus == SyncStatus.LOCAL_ONLY || it.syncStatus == SyncStatus.PENDING + } + val totalToUpload = pendingNotes.size + // 🔧 v1.7.2 (IMPL_004): Batch E-Tag Updates für Performance val etagUpdates = mutableMapOf() @@ -798,6 +861,9 @@ class WebDavSyncService(private val context: Context) { storage.saveNote(noteToUpload) uploadedCount++ + // 🆕 v1.8.0: Progress mit Notiz-Titel + onProgress(uploadedCount, totalToUpload, note.title) + // ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download // 🔧 v1.7.2 (IMPL_004): Sammle E-Tags für Batch-Update try { @@ -984,71 +1050,135 @@ class WebDavSyncService(private val context: Context) { val sardine = SafeSardineWrapper.create(okHttpClient, username, password) - val mdUrl = getMarkdownUrl(serverUrl) - - // Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck - ensureMarkdownDirectoryExists(sardine, serverUrl) - - // Hole ALLE lokalen Notizen (inklusive SYNCED) - val allNotes = storage.loadAllNotes() - val totalCount = allNotes.size - var exportedCount = 0 - - // Track used filenames to handle duplicates - val usedFilenames = mutableSetOf() - - Logger.d(TAG, "📝 Found $totalCount notes to export") - - allNotes.forEachIndexed { index, note -> - try { - // Progress-Callback - onProgress(index + 1, totalCount) - - // Eindeutiger Filename (mit Duplikat-Handling) - val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md" - val noteUrl = "$mdUrl/$filename" - - // Konvertiere zu Markdown - val mdContent = note.toMarkdown().toByteArray() - - // Upload (überschreibt falls vorhanden) - sardine.put(noteUrl, mdContent, "text/markdown") - - exportedCount++ - Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename") - - } catch (e: Exception) { - Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}") - // Continue mit nächster Note (keine Abbruch bei Einzelfehlern) + try { + val mdUrl = getMarkdownUrl(serverUrl) + + // Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck + ensureMarkdownDirectoryExists(sardine, serverUrl) + + // Hole ALLE lokalen Notizen (inklusive SYNCED) + val allNotes = storage.loadAllNotes() + val totalCount = allNotes.size + var exportedCount = 0 + + // Track used filenames to handle duplicates + val usedFilenames = mutableSetOf() + + Logger.d(TAG, "📝 Found $totalCount notes to export") + + allNotes.forEachIndexed { index, note -> + try { + // Progress-Callback + onProgress(index + 1, totalCount) + + // Eindeutiger Filename (mit Duplikat-Handling) + val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md" + val noteUrl = "$mdUrl/$filename" + + // Konvertiere zu Markdown + val mdContent = note.toMarkdown().toByteArray() + + // Upload (überschreibt falls vorhanden) + sardine.put(noteUrl, mdContent, "text/markdown") + + exportedCount++ + Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename") + + } catch (e: Exception) { + Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}") + // Continue mit nächster Note (keine Abbruch bei Einzelfehlern) + } } + + Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes") + + // ⚡ v1.3.1: Set lastSyncTimestamp to enable timestamp-based skip on next sync + // This prevents re-downloading all MD files on the first manual sync after initial export + if (exportedCount > 0) { + val timestamp = System.currentTimeMillis() + prefs.edit().putLong("last_sync_timestamp", timestamp).apply() + Logger.d(TAG, "💾 Set lastSyncTimestamp after initial export (enables fast next sync)") + } + + return@withContext exportedCount + } finally { + // 🐛 FIX: Connection Leak — SafeSardineWrapper explizit schließen + sardine.close() } - - Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes") - - // ⚡ v1.3.1: Set lastSyncTimestamp to enable timestamp-based skip on next sync - // This prevents re-downloading all MD files on the first manual sync after initial export - if (exportedCount > 0) { - val timestamp = System.currentTimeMillis() - prefs.edit().putLong("last_sync_timestamp", timestamp).apply() - Logger.d(TAG, "💾 Set lastSyncTimestamp after initial export (enables fast next sync)") - } - - return@withContext exportedCount } private data class DownloadResult( val downloadedCount: Int, - val conflictCount: Int + val conflictCount: Int, + val deletedOnServerCount: Int = 0 // 🆕 v1.8.0 ) - @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") + /** + * 🆕 v1.8.0: Erkennt Notizen, die auf dem Server gelöscht wurden + * + * Keine zusätzlichen HTTP-Requests! Nutzt die bereits geladene + * serverNoteIds-Liste aus dem PROPFIND-Request. + * + * Prüft ALLE Notizen (Notes + Checklists), da beide als + * JSON in /notes/{id}.json gespeichert werden. + * NoteType (NOTE vs CHECKLIST) spielt keine Rolle für die Detection. + * + * @param serverNoteIds Set aller Note-IDs auf dem Server (aus PROPFIND) + * @param localNotes Alle lokalen Notizen + * @return Anzahl der als DELETED_ON_SERVER markierten Notizen + */ + private fun detectServerDeletions( + serverNoteIds: Set, + localNotes: List + ): Int { + var deletedCount = 0 + val syncedNotes = localNotes.filter { it.syncStatus == SyncStatus.SYNCED } + + // 🆕 v1.8.0 (IMPL_022): Statistik-Log für Debugging + Logger.d(TAG, "🔍 detectServerDeletions: " + + "serverNotes=${serverNoteIds.size}, " + + "localSynced=${syncedNotes.size}, " + + "localTotal=${localNotes.size}") + + syncedNotes.forEach { note -> + // Nur SYNCED-Notizen prüfen: + // - LOCAL_ONLY: War nie auf Server → irrelevant + // - PENDING: Soll hochgeladen werden → nicht überschreiben + // - CONFLICT: Wird separat behandelt + // - DELETED_ON_SERVER: Bereits markiert + if (note.id !in serverNoteIds) { + val updatedNote = note.copy(syncStatus = SyncStatus.DELETED_ON_SERVER) + storage.saveNote(updatedNote) + deletedCount++ + + Logger.d(TAG, "🗑️ Note '${note.title}' (${note.id}) " + + "was deleted on server, marked as DELETED_ON_SERVER") + } + } + + if (deletedCount > 0) { + Logger.d(TAG, "📊 Server deletion detection complete: " + + "$deletedCount of ${syncedNotes.size} synced notes deleted on server") + } + + return deletedCount + } + + @Suppress( + "NestedBlockDepth", + "LoopWithTooManyJumpStatements", + "LongMethod", + "ComplexMethod" + ) // Sync logic requires nested conditions for comprehensive error handling and conflict resolution + // TODO: Refactor into smaller functions in v1.9.0/v2.0.0 (see LINT_DETEKT_FEHLER_BEHEBUNG_PLAN.md) private fun downloadRemoteNotes( sardine: Sardine, serverUrl: String, includeRootFallback: Boolean = false, // 🆕 v1.2.2: Only for restore from server forceOverwrite: Boolean = false, // 🆕 v1.3.0: For OVERWRITE_DUPLICATES mode - deletionTracker: DeletionTracker = storage.loadDeletionTracker() // 🆕 v1.3.0: Allow passing fresh tracker + deletionTracker: DeletionTracker = storage.loadDeletionTracker(), // 🆕 v1.3.0: Allow passing fresh tracker + onProgress: (current: Int, total: Int, fileName: String) -> Unit = { _, _, _ -> } // 🆕 v1.8.0 ): DownloadResult { var downloadedCount = 0 var conflictCount = 0 @@ -1062,6 +1192,9 @@ class WebDavSyncService(private val context: Context) { // Use provided deletion tracker (allows fresh tracker from restore) var trackerModified = false + // 🆕 v1.8.0: Collect server note IDs for deletion detection + val serverNoteIds = mutableSetOf() + try { // 🆕 PHASE 1: Download from /notes/ (new structure v1.2.1+) val notesUrl = getNotesUrl(serverUrl) @@ -1076,17 +1209,27 @@ class WebDavSyncService(private val context: Context) { val resources = sardine.list(notesUrl) val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") } Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server") - + + // 🆕 v1.8.0: Extract server note IDs + jsonFiles.forEach { resource -> + val noteId = resource.name.removeSuffix(".json") + serverNoteIds.add(noteId) + } + + // ════════════════════════════════════════════════════════════════ + // 🆕 v1.8.0: PHASE 1A - Collect Download Tasks + // ════════════════════════════════════════════════════════════════ + val downloadTasks = mutableListOf() + for (resource in jsonFiles) { - val noteId = resource.name.removeSuffix(".json") val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name - + // ⚡ v1.3.1: HYBRID PERFORMANCE - Timestamp + E-Tag (like Markdown!) val serverETag = resource.etag val cachedETag = prefs.getString("etag_json_$noteId", null) val serverModified = resource.modified?.time ?: 0L - + // 🐛 DEBUG: Log every file check to diagnose performance val serverETagPreview = serverETag?.take(ETAG_PREVIEW_LENGTH) ?: "null" val cachedETagPreview = cachedETag?.take(ETAG_PREVIEW_LENGTH) ?: "null" @@ -1095,11 +1238,11 @@ class WebDavSyncService(private val context: Context) { " 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview " + "modified=$serverModified lastSync=$lastSyncTime" ) - + // FIRST: Check deletion tracker - if locally deleted, skip unless re-created on server if (deletionTracker.isDeleted(noteId)) { val deletedAt = deletionTracker.getDeletionTimestamp(noteId) - + // Smart check: Was note re-created on server after deletion? if (deletedAt != null && serverModified > deletedAt) { Logger.d(TAG, " 📝 Note re-created on server after deletion: $noteId") @@ -1113,11 +1256,11 @@ class WebDavSyncService(private val context: Context) { continue } } - + // Check if file exists locally val localNote = storage.loadNote(noteId) val fileExistsLocally = localNote != null - + // PRIMARY: Timestamp check (works on first sync!) // Same logic as Markdown sync - skip if not modified since last sync // BUT: Always download if file doesn't exist locally! @@ -1127,7 +1270,7 @@ class WebDavSyncService(private val context: Context) { processedIds.add(noteId) continue } - + // SECONDARY: E-Tag check (for performance after first sync) // Catches cases where file was re-uploaded with same content // BUT: Always download if file doesn't exist locally! @@ -1137,12 +1280,12 @@ class WebDavSyncService(private val context: Context) { processedIds.add(noteId) continue } - + // If file doesn't exist locally, always download if (!fileExistsLocally) { Logger.d(TAG, " 📥 File missing locally - forcing download") } - + // 🐛 DEBUG: Log download reason val downloadReason = when { lastSyncTime == 0L -> "First sync ever" @@ -1154,61 +1297,131 @@ class WebDavSyncService(private val context: Context) { else -> "E-Tag changed" } Logger.d(TAG, " 📥 Downloading $noteId: $downloadReason") - - // Download and process - val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } - val remoteNote = Note.fromJson(jsonContent) ?: continue - - processedIds.add(remoteNote.id) // 🆕 Mark as processed - - // Note: localNote was already loaded above for existence check - when { - localNote == null -> { - // New note from server - storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) - downloadedCount++ - Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}") - - // ⚡ Cache E-Tag for next sync - if (serverETag != null) { - prefs.edit().putString("etag_json_$noteId", serverETag).apply() - } - } - forceOverwrite -> { - // OVERWRITE mode: Always replace regardless of timestamps - storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) - downloadedCount++ - Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}") - - // ⚡ Cache E-Tag for next sync - if (serverETag != null) { - prefs.edit().putString("etag_json_$noteId", serverETag).apply() - } - } - localNote.updatedAt < remoteNote.updatedAt -> { - // Remote is newer - if (localNote.syncStatus == SyncStatus.PENDING) { - // Conflict detected - storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) - conflictCount++ - } else { - // Safe to overwrite - storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) - downloadedCount++ - Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}") - - // ⚡ Cache E-Tag for next sync - if (serverETag != null) { - prefs.edit().putString("etag_json_$noteId", serverETag).apply() + + // 🆕 v1.8.0: Add to download tasks + downloadTasks.add(DownloadTask( + noteId = noteId, + url = noteUrl, + resource = resource, + serverETag = serverETag, + serverModified = serverModified + )) + } + + Logger.d(TAG, " 📋 ${downloadTasks.size} files to download, $skippedDeleted skipped (deleted), " + + "$skippedUnchanged skipped (unchanged)") + + // ════════════════════════════════════════════════════════════════ + // 🆕 v1.8.0: PHASE 1B - Parallel Download + // ════════════════════════════════════════════════════════════════ + if (downloadTasks.isNotEmpty()) { + // Konfigurierbare Parallelität aus Settings + val maxParallel = prefs.getInt( + Constants.KEY_MAX_PARALLEL_DOWNLOADS, + Constants.DEFAULT_MAX_PARALLEL_DOWNLOADS + ) + + val downloader = ParallelDownloader( + sardine = sardine, + maxParallelDownloads = maxParallel + ) + + downloader.onProgress = { completed, total, currentFile -> + onProgress(completed, total, currentFile ?: "?") + } + + val downloadResults = runBlocking { + downloader.downloadAll(downloadTasks) + } + + // ════════════════════════════════════════════════════════════════ + // 🆕 v1.8.0: PHASE 1C - Process Results + // ════════════════════════════════════════════════════════════════ + Logger.d(TAG, " 🔄 Processing ${downloadResults.size} download results") + + // Batch-collect E-Tags for single write + val etagUpdates = mutableMapOf() + + for (result in downloadResults) { + when (result) { + is DownloadTaskResult.Success -> { + val remoteNote = Note.fromJson(result.content) + if (remoteNote == null) { + Logger.w(TAG, " ⚠️ Failed to parse JSON: ${result.noteId}") + continue } + + processedIds.add(remoteNote.id) + val localNote = storage.loadNote(remoteNote.id) + + when { + localNote == null -> { + // New note from server + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}") + + // ⚡ Batch E-Tag for later + if (result.etag != null) { + etagUpdates["etag_json_${result.noteId}"] = result.etag + } + } + forceOverwrite -> { + // OVERWRITE mode: Always replace regardless of timestamps + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}") + + if (result.etag != null) { + etagUpdates["etag_json_${result.noteId}"] = result.etag + } + } + localNote.updatedAt < remoteNote.updatedAt -> { + // Remote is newer + if (localNote.syncStatus == SyncStatus.PENDING) { + // Conflict detected + storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) + conflictCount++ + Logger.w(TAG, " ⚠️ Conflict: ${remoteNote.id}") + } else { + // Safe to overwrite + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}") + + if (result.etag != null) { + etagUpdates["etag_json_${result.noteId}"] = result.etag + } + } + } + // else: Local is newer or same → skip silently + } + } + is DownloadTaskResult.Failure -> { + Logger.e(TAG, " ❌ Download failed: ${result.noteId} - ${result.error.message}") + // Fehlerhafte Downloads nicht als verarbeitet markieren + // → werden beim nächsten Sync erneut versucht + } + is DownloadTaskResult.Skipped -> { + Logger.d(TAG, " ⏭️ Skipped: ${result.noteId} - ${result.reason}") + processedIds.add(result.noteId) } } } + + // ⚡ Batch-save E-Tags (IMPL_004 optimization) + if (etagUpdates.isNotEmpty()) { + prefs.edit().apply { + etagUpdates.forEach { (key, value) -> putString(key, value) } + }.apply() + Logger.d(TAG, " 💾 Batch-saved ${etagUpdates.size} E-Tags") + } } + Logger.d( TAG, - " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), " + - "$skippedUnchanged skipped (unchanged)" + " 📊 Phase 1: $downloadedCount downloaded, $conflictCount conflicts, " + + "$skippedDeleted skipped (deleted), $skippedUnchanged skipped (unchanged)" ) } else { Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1") @@ -1317,8 +1530,16 @@ class WebDavSyncService(private val context: Context) { Logger.d(TAG, "💾 Deletion tracker updated") } + // 🆕 v1.8.0: Server-Deletions erkennen (nach Downloads) + val allLocalNotes = storage.loadAllNotes() + val deletedOnServerCount = detectServerDeletions(serverNoteIds, allLocalNotes) + + if (deletedOnServerCount > 0) { + Logger.d(TAG, "$deletedOnServerCount note(s) detected as deleted on server") + } + Logger.d(TAG, "📊 Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted") - return DownloadResult(downloadedCount, conflictCount) + return DownloadResult(downloadedCount, conflictCount, deletedOnServerCount) } private fun saveLastSyncTimestamp() { @@ -1507,59 +1728,64 @@ class WebDavSyncService(private val context: Context) { val okHttpClient = OkHttpClient.Builder().build() val sardine = SafeSardineWrapper.create(okHttpClient, username, password) - val mdUrl = getMarkdownUrl(serverUrl) - - // Check if notes-md/ exists - if (!sardine.exists(mdUrl)) { - Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import") - return@withContext 0 - } - - val localNotes = storage.loadAllNotes() - val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") } - var importedCount = 0 - - Logger.d(TAG, "📂 Found ${mdResources.size} markdown files") - - for (resource in mdResources) { - try { - // Download MD-File - val mdContent = sardine.get(resource.href.toString()) - .bufferedReader().use { it.readText() } - - // Parse zu Note - val mdNote = Note.fromMarkdown(mdContent) ?: continue - - val localNote = localNotes.find { it.id == mdNote.id } - - // Konfliktauflösung: Last-Write-Wins - when { - localNote == null -> { - // Neue Notiz vom Desktop - storage.saveNote(mdNote) - importedCount++ - Logger.d(TAG, " ✅ Imported new: ${mdNote.title}") - } - mdNote.updatedAt > localNote.updatedAt -> { - // Desktop-Version ist neuer (Last-Write-Wins) - storage.saveNote(mdNote) - importedCount++ - Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}") - } - // Sonst: Lokale Version behalten - else -> { - Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}") - } - } - } catch (e: Exception) { - Logger.e(TAG, "Failed to import ${resource.name}", e) - // Continue with other files + try { + val mdUrl = getMarkdownUrl(serverUrl) + + // Check if notes-md/ exists + if (!sardine.exists(mdUrl)) { + Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import") + return@withContext 0 } + + val localNotes = storage.loadAllNotes() + val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") } + var importedCount = 0 + + Logger.d(TAG, "📂 Found ${mdResources.size} markdown files") + + for (resource in mdResources) { + try { + // Download MD-File + val mdContent = sardine.get(resource.href.toString()) + .bufferedReader().use { it.readText() } + + // Parse zu Note + val mdNote = Note.fromMarkdown(mdContent) ?: continue + + val localNote = localNotes.find { it.id == mdNote.id } + + // Konfliktauflösung: Last-Write-Wins + when { + localNote == null -> { + // Neue Notiz vom Desktop + storage.saveNote(mdNote) + importedCount++ + Logger.d(TAG, " ✅ Imported new: ${mdNote.title}") + } + mdNote.updatedAt > localNote.updatedAt -> { + // Desktop-Version ist neuer (Last-Write-Wins) + storage.saveNote(mdNote) + importedCount++ + Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}") + } + // Sonst: Lokale Version behalten + else -> { + Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}") + } + } + } catch (e: Exception) { + Logger.e(TAG, "Failed to import ${resource.name}", e) + // Continue with other files + } + } + + Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported") + importedCount + } finally { + // 🐛 FIX: Connection Leak — SafeSardineWrapper explizit schließen + sardine.close() } - Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported") - importedCount - } catch (e: Exception) { Logger.e(TAG, "Markdown sync failed", e) 0 diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/parallel/DownloadTask.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/parallel/DownloadTask.kt new file mode 100644 index 0000000..fb2d92e --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/parallel/DownloadTask.kt @@ -0,0 +1,63 @@ +package dev.dettmer.simplenotes.sync.parallel + +import com.thegrizzlylabs.sardineandroid.DavResource + +/** + * 🆕 v1.8.0: Repräsentiert einen einzelnen Download-Task + * + * @param noteId Die ID der Notiz (ohne .json Extension) + * @param url Vollständige URL zur JSON-Datei + * @param resource WebDAV-Resource mit Metadaten + * @param serverETag E-Tag vom Server (für Caching) + * @param serverModified Letztes Änderungsdatum vom Server (Unix timestamp) + */ +data class DownloadTask( + val noteId: String, + val url: String, + val resource: DavResource, + val serverETag: String?, + val serverModified: Long +) + +/** + * 🆕 v1.8.0: Ergebnis eines einzelnen Downloads + * + * Sealed class für typ-sichere Verarbeitung von Download-Ergebnissen. + * Jeder Download kann erfolgreich sein, fehlschlagen oder übersprungen werden. + */ +sealed class DownloadTaskResult { + /** + * Download erfolgreich abgeschlossen + * + * @param noteId Die ID der heruntergeladenen Notiz + * @param content JSON-Inhalt der Notiz + * @param etag E-Tag vom Server (für zukünftiges Caching) + */ + data class Success( + val noteId: String, + val content: String, + val etag: String? + ) : DownloadTaskResult() + + /** + * Download fehlgeschlagen + * + * @param noteId Die ID der Notiz, die nicht heruntergeladen werden konnte + * @param error Der aufgetretene Fehler + */ + data class Failure( + val noteId: String, + val error: Throwable + ) : DownloadTaskResult() + + /** + * Download übersprungen (z.B. wegen gelöschter Notiz) + * + * @param noteId Die ID der übersprungenen Notiz + * @param reason Grund für das Überspringen + */ + data class Skipped( + val noteId: String, + val reason: String + ) : DownloadTaskResult() +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/parallel/ParallelDownloader.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/parallel/ParallelDownloader.kt new file mode 100644 index 0000000..348bf7a --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/parallel/ParallelDownloader.kt @@ -0,0 +1,138 @@ +package dev.dettmer.simplenotes.sync.parallel + +import com.thegrizzlylabs.sardineandroid.Sardine +import dev.dettmer.simplenotes.utils.Logger +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.atomic.AtomicInteger + +/** + * 🆕 v1.8.0: Paralleler Download-Handler für Notizen + * + * Features: + * - Konfigurierbare max. parallele Downloads (default: 5) + * - Graceful Error-Handling (einzelne Fehler stoppen nicht den ganzen Sync) + * - Progress-Callback für UI-Updates + * - Retry-Logic für transiente Fehler mit Exponential Backoff + * + * Performance: + * - 100 Notizen: ~20s → ~4s (5x schneller) + * - 50 Notizen: ~10s → ~2s + * + * @param sardine WebDAV-Client für Downloads + * @param maxParallelDownloads Maximale Anzahl gleichzeitiger Downloads (1-10) + * @param retryCount Anzahl der Wiederholungsversuche bei Fehlern + */ +class ParallelDownloader( + private val sardine: Sardine, + private val maxParallelDownloads: Int = DEFAULT_MAX_PARALLEL, + private val retryCount: Int = DEFAULT_RETRY_COUNT +) { + companion object { + private const val TAG = "ParallelDownloader" + const val DEFAULT_MAX_PARALLEL = 5 + const val DEFAULT_RETRY_COUNT = 2 + private const val RETRY_DELAY_MS = 500L + } + + /** + * Download-Progress Callback + * + * @param completed Anzahl abgeschlossener Downloads + * @param total Gesamtanzahl Downloads + * @param currentFile Aktueller Dateiname (optional) + */ + var onProgress: ((completed: Int, total: Int, currentFile: String?) -> Unit)? = null + + /** + * Führt parallele Downloads aus + * + * Die Downloads werden mit einem Semaphore begrenzt, um Server-Überlastung + * zu vermeiden. Jeder Download wird unabhängig behandelt - Fehler in einem + * Download stoppen nicht die anderen. + * + * @param tasks Liste der Download-Tasks + * @return Liste der Ergebnisse (Success, Failure, Skipped) + */ + suspend fun downloadAll( + tasks: List + ): List = coroutineScope { + + if (tasks.isEmpty()) { + Logger.d(TAG, "⏭️ No tasks to download") + return@coroutineScope emptyList() + } + + Logger.d(TAG, "🚀 Starting parallel download: ${tasks.size} tasks, max $maxParallelDownloads concurrent") + + val semaphore = Semaphore(maxParallelDownloads) + val completedCount = AtomicInteger(0) + val totalCount = tasks.size + + val jobs = tasks.map { task -> + async(Dispatchers.IO) { + semaphore.withPermit { + val result = downloadWithRetry(task) + + // Progress Update + val completed = completedCount.incrementAndGet() + onProgress?.invoke(completed, totalCount, task.noteId) + + result + } + } + } + + // Warte auf alle Downloads + val results = jobs.awaitAll() + + // Statistiken loggen + val successCount = results.count { it is DownloadTaskResult.Success } + val failureCount = results.count { it is DownloadTaskResult.Failure } + val skippedCount = results.count { it is DownloadTaskResult.Skipped } + + Logger.d(TAG, "📊 Download complete: $successCount success, $failureCount failed, $skippedCount skipped") + + results + } + + /** + * Download mit Retry-Logic und Exponential Backoff + * + * Versucht den Download bis zu (retryCount + 1) mal. Bei jedem Fehlversuch + * wird exponentiell länger gewartet (500ms, 1000ms, 1500ms, ...). + * + * @param task Der Download-Task + * @return Ergebnis des Downloads (Success oder Failure) + */ + private suspend fun downloadWithRetry(task: DownloadTask): DownloadTaskResult { + var lastError: Throwable? = null + + repeat(retryCount + 1) { attempt -> + try { + val content = sardine.get(task.url).bufferedReader().use { it.readText() } + + Logger.d(TAG, "✅ Downloaded ${task.noteId} (attempt ${attempt + 1})") + + return DownloadTaskResult.Success( + noteId = task.noteId, + content = content, + etag = task.serverETag + ) + + } catch (e: Exception) { + lastError = e + Logger.w(TAG, "⚠️ Download failed ${task.noteId} (attempt ${attempt + 1}): ${e.message}") + + // Retry nach Delay (außer beim letzten Versuch) + if (attempt < retryCount) { + delay(RETRY_DELAY_MS * (attempt + 1)) // Exponential backoff + } + } + } + + Logger.e(TAG, "❌ Download failed after ${retryCount + 1} attempts: ${task.noteId}") + return DownloadTaskResult.Failure(task.noteId, lastError ?: Exception("Unknown error")) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/ComposeNoteEditorActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/ComposeNoteEditorActivity.kt index 8cd9c6e..4dbdcec 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/ComposeNoteEditorActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/ComposeNoteEditorActivity.kt @@ -79,6 +79,18 @@ class ComposeNoteEditorActivity : ComponentActivity() { } } } + + /** + * 🆕 v1.8.0 (IMPL_025): Reload Checklist-State falls Widget Änderungen gemacht hat. + * + * Wenn die Activity aus dem Hintergrund zurückkehrt (z.B. nach Widget-Toggle), + * wird der aktuelle Note-Stand von Disk geladen und der ViewModel-State + * für Checklist-Items aktualisiert. + */ + override fun onResume() { + super.onResume() + viewModel.reloadFromStorage() + } } /** diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt index 0889ac0..96be346 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -19,9 +20,11 @@ import kotlinx.coroutines.launch /** * FOSS Drag & Drop State für LazyList - * + * * Native Compose-Implementierung ohne externe Dependencies * v1.5.0: NoteEditor Redesign + * v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag) + * v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt) */ class DragDropListState( private val state: LazyListState, @@ -64,11 +67,17 @@ class DragDropListState( val startOffset = draggingItem.offset + draggingItemOffset val endOffset = startOffset + draggingItem.size - val middleOffset = startOffset + (endOffset - startOffset) / 2f - - val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> - middleOffset.toInt() in item.offset..item.offsetEnd && - draggingItem.index != item.index + // 🆕 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. + val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item -> + (item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) && + run { + val targetCenter = item.offset + item.size / 2 + startOffset < targetCenter && endOffset > targetCenter + } } if (targetItem != null) { @@ -84,12 +93,13 @@ class DragDropListState( scope.launch { state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) onMove(draggingItem.index, targetItem.index) + // 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition) + draggingItemIndex = targetItem.index } } else { onMove(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index } - - draggingItemIndex = targetItem.index } else { val overscroll = when { draggingItemDraggedDelta > 0 -> @@ -111,6 +121,7 @@ class DragDropListState( } } + @Suppress("UnusedPrivateProperty") private val LazyListItemInfo.offsetEnd: Int get() = this.offset + this.size } @@ -130,14 +141,16 @@ fun rememberDragDropListState( } } +@Composable fun Modifier.dragContainer( dragDropState: DragDropListState, itemIndex: Int ): Modifier { - return this.pointerInput(dragDropState) { + val currentIndex = rememberUpdatedState(itemIndex) // 🆕 v1.8.0: rememberUpdatedState statt Key + return this.pointerInput(dragDropState) { // Nur dragDropState als Key - verhindert Gesture-Restart detectDragGesturesAfterLongPress( onDragStart = { offset -> - dragDropState.onDragStart(offset, itemIndex) + dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen }, onDragEnd = { dragDropState.onDragInterrupted() diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt index d5f53fa..83c49d6 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt @@ -1,10 +1,16 @@ 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 import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -18,6 +24,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Sort import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Save @@ -50,14 +57,22 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.ChecklistSortOption import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow +import dev.dettmer.simplenotes.ui.editor.components.ChecklistSortDialog import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import kotlinx.coroutines.delay import dev.dettmer.simplenotes.utils.showToast import kotlin.math.roundToInt +private const val LAYOUT_DELAY_MS = 100L +private const val ITEM_CORNER_RADIUS_DP = 8 +private const val DRAGGING_ITEM_Z_INDEX = 10f + /** * Main Composable for the Note Editor screen. * @@ -80,6 +95,8 @@ fun NoteEditorScreen( val isOfflineMode by viewModel.isOfflineMode.collectAsState() var showDeleteDialog by remember { mutableStateOf(false) } + var showChecklistSortDialog by remember { mutableStateOf(false) } // 🔀 v1.8.0 + val lastChecklistSortOption by viewModel.lastChecklistSortOption.collectAsState() // 🔀 v1.8.0 var focusNewItemId by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -95,7 +112,7 @@ fun NoteEditorScreen( // v1.5.0: Auto-focus and show keyboard LaunchedEffect(uiState.isNewNote, uiState.noteType) { - delay(100) // Wait for layout + delay(LAYOUT_DELAY_MS) // Wait for layout when { uiState.isNewNote -> { // New note: focus title @@ -215,6 +232,7 @@ fun NoteEditorScreen( items = checklistItems, scope = scope, focusNewItemId = focusNewItemId, + currentSortOption = lastChecklistSortOption, // 🔀 v1.8.0 onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) }, onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) }, onDelete = { id -> viewModel.deleteChecklistItem(id) }, @@ -228,6 +246,7 @@ fun NoteEditorScreen( }, onMove = { from, to -> viewModel.moveChecklistItem(from, to) }, onFocusHandled = { focusNewItemId = null }, + onSortClick = { showChecklistSortDialog = true }, // 🔀 v1.8.0 modifier = Modifier .fillMaxWidth() .weight(1f) @@ -253,6 +272,18 @@ fun NoteEditorScreen( } ) } + + // 🔀 v1.8.0: Checklist Sort Dialog + if (showChecklistSortDialog) { + ChecklistSortDialog( + currentOption = lastChecklistSortOption, + onOptionSelected = { option -> + viewModel.sortChecklistItems(option) + showChecklistSortDialog = false + }, + onDismiss = { showChecklistSortDialog = false } + ) + } } @Composable @@ -302,6 +333,7 @@ private fun ChecklistEditor( items: List, scope: kotlinx.coroutines.CoroutineScope, focusNewItemId: String?, + currentSortOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Sortierung onTextChange: (String, String) -> Unit, onCheckedChange: (String, Boolean) -> Unit, onDelete: (String) -> Unit, @@ -309,6 +341,7 @@ private fun ChecklistEditor( onAddItemAtEnd: () -> Unit, onMove: (Int, Int) -> Unit, onFocusHandled: () -> Unit, + onSortClick: () -> Unit, // 🔀 v1.8.0 modifier: Modifier = Modifier ) { val listState = rememberLazyListState() @@ -317,7 +350,14 @@ private fun ChecklistEditor( scope = scope, onMove = onMove ) - + + // 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen + val uncheckedCount = items.count { !it.isChecked } + val checkedCount = items.count { it.isChecked } + val shouldShowSeparator = currentSortOption == ChecklistSortOption.MANUAL || + currentSortOption == ChecklistSortOption.UNCHECKED_FIRST + val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0 + Column(modifier = modifier) { LazyColumn( state = listState, @@ -329,56 +369,89 @@ private fun ChecklistEditor( items = 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" ) - + val shouldFocus = item.id == focusNewItemId - + // v1.5.0: Clear focus request after handling 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, - modifier = Modifier - .dragContainer(dragDropState, index) - .offset { - IntOffset( - 0, - if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 + + // 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + ChecklistItemRow( + 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) ) - } - .shadow(elevation, shape = RoundedCornerShape(8.dp)) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(8.dp) - ) - ) + ) + } } } - - // Add Item Button - TextButton( - onClick = onAddItemAtEnd, - modifier = Modifier.padding(start = 8.dp) + + // 🔀 v1.8.0: Add Item Button + Sort Button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp) - ) - Text(stringResource(R.string.add_item)) + TextButton(onClick = onAddItemAtEnd) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(R.string.add_item)) + } + + IconButton(onClick = onSortClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Sort, + contentDescription = stringResource(R.string.sort_checklist), + modifier = androidx.compose.ui.Modifier.padding(4.dp) + ) + } } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt index 3a67d67..8f634a5 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import dev.dettmer.simplenotes.models.ChecklistItem +import dev.dettmer.simplenotes.models.ChecklistSortOption import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.SyncStatus @@ -65,6 +66,10 @@ class NoteEditorViewModel( ) val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow() + // 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope) + private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL) + val lastChecklistSortOption: StateFlow = _lastChecklistSortOption.asStateFlow() + // ═══════════════════════════════════════════════════════════════════════ // Events // ═══════════════════════════════════════════════════════════════════════ @@ -104,7 +109,7 @@ class NoteEditorViewModel( } if (note.noteType == NoteType.CHECKLIST) { - val items = note.checklistItems?.sortedBy { it.order }?.map { + val items = note.checklistItems?.sortedBy { it.order }?.map { ChecklistItemState( id = it.id, text = it.text, @@ -112,7 +117,8 @@ class NoteEditorViewModel( order = it.order ) } ?: emptyList() - _checklistItems.value = items + // 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind) + _checklistItems.value = sortChecklistItems(items) } } } else { @@ -163,11 +169,32 @@ class NoteEditorViewModel( } } + /** + * 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten. + * Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten. + */ + private fun sortChecklistItems(items: List): List { + val unchecked = items.filter { !it.isChecked } + val checked = items.filter { it.isChecked } + + return (unchecked + checked).mapIndexed { index, item -> + item.copy(order = index) + } + } + fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) { _checklistItems.update { items -> - items.map { item -> + val updatedItems = items.map { item -> if (item.id == itemId) item.copy(isChecked = isChecked) else item } + // 🆕 v1.8.0 (IMPL_017 + IMPL_020): Auto-Sort nur bei MANUAL und UNCHECKED_FIRST + val currentSort = _lastChecklistSortOption.value + if (currentSort == ChecklistSortOption.MANUAL || currentSort == ChecklistSortOption.UNCHECKED_FIRST) { + sortChecklistItems(updatedItems) + } else { + // Bei anderen Sortierungen (alphabetisch, checked first) nicht auto-sortieren + updatedItems.mapIndexed { index, item -> item.copy(order = index) } + } } } @@ -208,6 +235,15 @@ class NoteEditorViewModel( fun moveChecklistItem(fromIndex: Int, toIndex: Int) { _checklistItems.update { items -> + val fromItem = items.getOrNull(fromIndex) ?: return@update items + val toItem = items.getOrNull(toIndex) ?: return@update items + + // 🆕 v1.8.0 (IMPL_017): Drag nur innerhalb der gleichen Gruppe erlauben + // (checked ↔ checked, unchecked ↔ unchecked) + if (fromItem.isChecked != toItem.isChecked) { + return@update items // Kein Move über Gruppen-Grenze + } + val mutableList = items.toMutableList() val item = mutableList.removeAt(fromIndex) mutableList.add(toIndex, item) @@ -216,6 +252,37 @@ class NoteEditorViewModel( } } + /** + * 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option. + * Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren. + */ + fun sortChecklistItems(option: ChecklistSortOption) { + // Merke die Auswahl für diesen Editor-Session + _lastChecklistSortOption.value = option + + _checklistItems.update { items -> + val sorted = when (option) { + // Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird + ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked } + + ChecklistSortOption.ALPHABETICAL_ASC -> + items.sortedBy { it.text.lowercase() } + + ChecklistSortOption.ALPHABETICAL_DESC -> + items.sortedByDescending { it.text.lowercase() } + + ChecklistSortOption.UNCHECKED_FIRST -> + items.sortedBy { it.isChecked } + + ChecklistSortOption.CHECKED_FIRST -> + items.sortedByDescending { it.isChecked } + } + + // Order-Werte neu zuweisen + sorted.mapIndexed { index, item -> item.copy(order = index) } + } + } + fun saveNote() { viewModelScope.launch { val state = _uiState.value @@ -231,6 +298,8 @@ class NoteEditorViewModel( } val note = if (existingNote != null) { + // 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt + // beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc. existingNote!!.copy( title = title, content = content, @@ -272,6 +341,8 @@ class NoteEditorViewModel( } val note = if (existingNote != null) { + // 🆕 v1.8.0 (IMPL_022): syncStatus wird immer auf PENDING gesetzt + // beim Bearbeiten - gilt für SYNCED, CONFLICT, DELETED_ON_SERVER, etc. existingNote!!.copy( title = title, content = "", // Empty for checklists @@ -296,10 +367,21 @@ class NoteEditorViewModel( } _events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED)) - + // 🌟 v1.6.0: Trigger onSave Sync triggerOnSaveSync() - + + // 🆕 v1.8.0: Betroffene Widgets aktualisieren + try { + val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(getApplication()) + val glanceIds = glanceManager.getGlanceIds(dev.dettmer.simplenotes.widget.NoteWidget::class.java) + glanceIds.forEach { id -> + dev.dettmer.simplenotes.widget.NoteWidget().update(getApplication(), id) + } + } catch (e: Exception) { + Logger.w(TAG, "Failed to update widgets: ${e.message}") + } + _events.emit(NoteEditorEvent.NavigateBack) } } @@ -347,6 +429,41 @@ class NoteEditorViewModel( } fun canDelete(): Boolean = existingNote != null + + /** + * 🆕 v1.8.0 (IMPL_025): Reload Note aus Storage nach Resume + * + * Wird aufgerufen wenn die Activity aus dem Hintergrund zurückkehrt. + * Liest den aktuellen Note-Stand von Disk und aktualisiert den ViewModel-State. + * + * Wird nur für existierende Checklist-Notes benötigt (neue Notes haben keinen + * externen Schreiber). Relevant für Widget-Checklist-Toggles. + * + * Nur checklistItems werden aktualisiert — nicht title oder content, + * damit ungespeicherte Text-Änderungen im Editor nicht verloren gehen. + */ + fun reloadFromStorage() { + val noteId = savedStateHandle.get(ARG_NOTE_ID) ?: return + + val freshNote = storage.loadNote(noteId) ?: return + + // Nur Checklist-Items aktualisieren + if (freshNote.noteType == NoteType.CHECKLIST) { + val freshItems = freshNote.checklistItems?.sortedBy { it.order }?.map { + ChecklistItemState( + id = it.id, + text = it.text, + isChecked = it.isChecked, + order = it.order + ) + } ?: return + + _checklistItems.value = sortChecklistItems(freshItems) + // existingNote aktualisieren damit beim Speichern der richtige + // Basis-State verwendet wird + existingNote = freshNote + } + } // ═══════════════════════════════════════════════════════════════════════════ // 🌟 v1.6.0: Sync Trigger - onSave diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/CheckedItemsSeparator.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/CheckedItemsSeparator.kt new file mode 100644 index 0000000..1b8e1c1 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/CheckedItemsSeparator.kt @@ -0,0 +1,54 @@ +package dev.dettmer.simplenotes.ui.editor.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R + +/** + * 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items + * + * Zeigt eine dezente Linie mit Anzahl der erledigten Items: + * ── 3 completed ── + */ +@Composable +fun CheckedItemsSeparator( + checkedCount: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outlineVariant + ) + + Text( + text = pluralStringResource( + R.plurals.checked_items_count, + checkedCount, + checkedCount + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 12.dp) + ) + + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outlineVariant + ) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt index c1c5f56..8076448 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt @@ -4,12 +4,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle @@ -21,6 +24,7 @@ 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.mutableStateOf import androidx.compose.runtime.remember @@ -30,22 +34,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.ui.editor.ChecklistItemState /** * A single row in the checklist editor with drag handle, checkbox, text input, and delete button. - * + * * v1.5.0: Jetpack Compose NoteEditor Redesign + * v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus) + * v1.8.0: IMPL_023 - Enlarged drag handle (48dp touch target) + drag modifier + * + * Note: Using 10 parameters for Composable is acceptable for complex UI components. + * @suppress LongParameterList - Composables naturally have many parameters */ +@Suppress("LongParameterList") @Composable fun ChecklistItemRow( item: ChecklistItemState, @@ -54,14 +68,42 @@ fun ChecklistItemRow( onDelete: () -> Unit, onAddNewItem: () -> Unit, requestFocus: Boolean = false, + isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state + isAnyItemDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Hide gradient during any drag + dragModifier: Modifier = Modifier, // 🆕 v1.8.0: IMPL_023 - Drag modifier for handle modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current + val density = LocalDensity.current var textFieldValue by remember(item.id) { - mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length))) + mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(0))) } - + + // 🆕 v1.8.0: Focus-State tracken für Expand/Collapse + var isFocused by remember { mutableStateOf(false) } + + // 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines) + var hasOverflow by remember { mutableStateOf(false) } + + // 🆕 v1.8.0: Höhe für collapsed-Ansicht (aus TextLayout berechnet) + var collapsedHeightDp by remember { mutableStateOf(null) } + + // 🆕 v1.8.0: ScrollState für dynamischen Gradient + val scrollState = rememberScrollState() + + // 🆕 v1.8.0: Scroll-basierter Ansatz aktiv wenn Höhe berechnet wurde + val useScrollClipping = hasOverflow && collapsedHeightDp != null + + // 🆕 v1.8.0: Dynamische Gradient-Sichtbarkeit basierend auf Scroll-Position + val showGradient = useScrollClipping && !isFocused && !isAnyItemDragging + val showTopGradient by remember { + derivedStateOf { showGradient && scrollState.value > 0 } + } + val showBottomGradient by remember { + derivedStateOf { showGradient && scrollState.value < scrollState.maxValue } + } + // v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items) LaunchedEffect(requestFocus) { if (requestFocus) { @@ -69,104 +111,169 @@ fun ChecklistItemRow( keyboardController?.show() } } - + + // 🆕 v1.8.0: Cursor ans Ende setzen wenn fokussiert (für Bearbeitung) + LaunchedEffect(isFocused) { + if (isFocused && textFieldValue.selection.start == 0) { + textFieldValue = textFieldValue.copy( + selection = TextRange(textFieldValue.text.length) + ) + } + } + // Update text field when external state changes LaunchedEffect(item.text) { if (textFieldValue.text != item.text) { textFieldValue = TextFieldValue( text = item.text, - selection = TextRange(item.text.length) + selection = if (isFocused) TextRange(item.text.length) else TextRange(0) ) } } - + val alpha = if (item.isChecked) 0.6f else 1.0f val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None - + @Suppress("MagicNumber") // UI padding values are self-explanatory Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically + .padding(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche) + verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch ) { - // Drag Handle - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = stringResource(R.string.drag_to_reorder), - modifier = Modifier - .size(24.dp) - .alpha(0.5f), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.width(4.dp)) - + // 🆕 v1.8.0: IMPL_023 - Vergrößerter Drag Handle (48dp Touch-Target) + Box( + modifier = dragModifier + .size(48.dp) // Material Design minimum touch target + .alpha(if (isDragging) 1.0f else 0.6f), // Visual feedback beim Drag + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource(R.string.drag_to_reorder), + modifier = Modifier.size(28.dp), // Icon größer als vorher (24dp → 28dp) + tint = if (isDragging) { + MaterialTheme.colorScheme.primary // Primary color während Drag + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + // Checkbox Checkbox( checked = item.isChecked, onCheckedChange = onCheckedChange, modifier = Modifier.alpha(alpha) ) - + Spacer(modifier = Modifier.width(4.dp)) - - // Text Input with placeholder - BasicTextField( - value = textFieldValue, - onValueChange = { newValue -> - // Check for newline (Enter key) - if (newValue.text.contains("\n")) { - val cleanText = newValue.text.replace("\n", "") - textFieldValue = TextFieldValue( - text = cleanText, - selection = TextRange(cleanText.length) - ) - onTextChange(cleanText) - onAddNewItem() + + // 🆕 v1.8.0: Text Input mit dynamischem Overflow-Gradient + Box(modifier = Modifier.weight(1f)) { + // Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed + Box( + modifier = if (!isFocused && useScrollClipping) { + Modifier + .heightIn(max = collapsedHeightDp!!) + .verticalScroll(scrollState) } else { - textFieldValue = newValue - onTextChange(newValue.text) + Modifier } - }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - .alpha(alpha), - textStyle = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurface, - textDecoration = textDecoration - ), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - keyboardActions = KeyboardActions( - onNext = { onAddNewItem() } - ), - singleLine = false, - maxLines = 5, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - decorationBox = { innerTextField -> - Box { - if (textFieldValue.text.isEmpty()) { - Text( - text = stringResource(R.string.item_placeholder), - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) { + BasicTextField( + value = textFieldValue, + onValueChange = { newValue -> + // Check for newline (Enter key) + if (newValue.text.contains("\n")) { + val cleanText = newValue.text.replace("\n", "") + textFieldValue = TextFieldValue( + text = cleanText, + selection = TextRange(cleanText.length) ) - ) + onTextChange(cleanText) + onAddNewItem() + } else { + textFieldValue = newValue + onTextChange(newValue.text) + } + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + } + .alpha(alpha), + textStyle = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurface, + textDecoration = textDecoration + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { onAddNewItem() } + ), + singleLine = false, + // maxLines nur als Fallback bis collapsedHeight berechnet ist + maxLines = if (isFocused || useScrollClipping) Int.MAX_VALUE else COLLAPSED_MAX_LINES, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + onTextLayout = { textLayoutResult -> + // 🆕 v1.8.0: Overflow erkennen - ABER NUR wenn kein Drag aktiv ist + if (!isAnyItemDragging) { + val overflow = textLayoutResult.lineCount > COLLAPSED_MAX_LINES + hasOverflow = overflow + // Höhe der ersten 5 Zeilen berechnen (einmalig) + if (overflow && collapsedHeightDp == null) { + collapsedHeightDp = with(density) { + textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp() + } + } + } + }, + decorationBox = { innerTextField -> + Box { + if (textFieldValue.text.isEmpty()) { + Text( + text = stringResource(R.string.item_placeholder), + style = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + ) + } + innerTextField() + } } - innerTextField() - } + ) } - ) - + + // 🆕 v1.8.0: Dynamischer Gradient basierend auf Scroll-Position + // Oben: sichtbar wenn nach unten gescrollt (Text oberhalb versteckt) + if (showTopGradient) { + OverflowGradient( + modifier = Modifier.align(Alignment.TopCenter), + isTopGradient = true + ) + } + + // Unten: sichtbar wenn noch Text unterhalb vorhanden + if (showBottomGradient) { + OverflowGradient( + modifier = Modifier.align(Alignment.BottomCenter), + isTopGradient = false + ) + } + } + Spacer(modifier = Modifier.width(4.dp)) - + // Delete Button IconButton( onClick = onDelete, - modifier = Modifier.size(36.dp) + modifier = Modifier + .size(36.dp) + .padding(top = 4.dp) // 🆕 v1.8.0: Ausrichtung mit Top-aligned Text ) { Icon( imageVector = Icons.Default.Close, @@ -177,3 +284,92 @@ fun ChecklistItemRow( } } } + +// 🆕 v1.8.0: Maximum lines when collapsed (not focused) +private const val COLLAPSED_MAX_LINES = 5 + +// ════════════════════════════════════════════════════════════════ +// 🆕 v1.8.0: Preview Composables for Manual Testing +// ════════════════════════════════════════════════════════════════ + +@Suppress("UnusedPrivateMember") +@Preview(showBackground = true) +@Composable +private fun ChecklistItemRowShortTextPreview() { + ChecklistItemRow( + item = ChecklistItemState( + id = "preview-1", + text = "Kurzer Text", + isChecked = false + ), + onTextChange = {}, + onCheckedChange = {}, + onDelete = {}, + onAddNewItem = {}, + isDragging = false, + dragModifier = Modifier + ) +} + +@Suppress("UnusedPrivateMember") +@Preview(showBackground = true) +@Composable +private fun ChecklistItemRowLongTextPreview() { + ChecklistItemRow( + item = ChecklistItemState( + id = "preview-2", + text = "Dies ist ein sehr langer Text der sich über viele Zeilen erstreckt " + + "und dazu dient den Overflow-Gradient zu demonstrieren. Er hat deutlich " + + "mehr als fünf Zeilen wenn er in der normalen Breite eines Smartphones " + + "angezeigt wird und sollte einen schönen Fade-Effekt am unteren Rand zeigen. " + + "Dieser zusätzliche Text sorgt dafür, dass wir wirklich genug Zeilen haben " + + "um den Gradient sichtbar zu machen.", + isChecked = false + ), + onTextChange = {}, + onCheckedChange = {}, + onDelete = {}, + onAddNewItem = {}, + isDragging = false, + dragModifier = Modifier + ) +} + +@Suppress("UnusedPrivateMember") +@Preview(showBackground = true) +@Composable +private fun ChecklistItemRowCheckedPreview() { + ChecklistItemRow( + item = ChecklistItemState( + id = "preview-3", + text = "Erledigte Aufgabe mit durchgestrichenem Text", + isChecked = true + ), + onTextChange = {}, + onCheckedChange = {}, + onDelete = {}, + onAddNewItem = {}, + isDragging = false, + dragModifier = Modifier + ) +} + +// 🆕 v1.8.0: IMPL_023 - Preview for dragging state +@Suppress("UnusedPrivateMember") +@Preview(showBackground = true) +@Composable +private fun ChecklistItemRowDraggingPreview() { + ChecklistItemRow( + item = ChecklistItemState( + id = "preview-4", + text = "Wird gerade verschoben - Handle ist highlighted", + isChecked = false + ), + onTextChange = {}, + onCheckedChange = {}, + onDelete = {}, + onAddNewItem = {}, + isDragging = true, + dragModifier = Modifier + ) +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistSortDialog.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistSortDialog.kt new file mode 100644 index 0000000..e7f2825 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistSortDialog.kt @@ -0,0 +1,123 @@ +package dev.dettmer.simplenotes.ui.editor.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.ChecklistSortOption + +/** + * 🔀 v1.8.0: Dialog zur Auswahl der Checklist-Sortierung. + * + * Einmalige Sortier-Aktion (nicht persistiert). + * User kann danach per Drag & Drop feinjustieren. + * + * ┌─────────────────────────────────┐ + * │ Sort Checklist │ + * ├─────────────────────────────────┤ + * │ ( ) Manual │ + * │ ( ) A → Z │ + * │ ( ) Z → A │ + * │ (●) Unchecked first │ + * │ ( ) Checked first │ + * ├─────────────────────────────────┤ + * │ [Cancel] [Apply] │ + * └─────────────────────────────────┘ + */ +@Composable +fun ChecklistSortDialog( + currentOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Auswahl merken + onOptionSelected: (ChecklistSortOption) -> Unit, + onDismiss: () -> Unit +) { + var selectedOption by remember { mutableStateOf(currentOption) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.sort_checklist), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column { + ChecklistSortOption.entries.forEach { option -> + SortOptionRow( + label = stringResource(option.toStringRes()), + isSelected = selectedOption == option, + onClick = { selectedOption = option } + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onOptionSelected(selectedOption) + } + ) { + Text(stringResource(R.string.apply)) + } + } + ) +} + +@Composable +private fun SortOptionRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onClick + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge + ) + } +} + +/** + * Extension: ChecklistSortOption → String-Resource-ID + */ +fun ChecklistSortOption.toStringRes(): Int = when (this) { + ChecklistSortOption.MANUAL -> R.string.sort_checklist_manual + ChecklistSortOption.ALPHABETICAL_ASC -> R.string.sort_checklist_alpha_asc + ChecklistSortOption.ALPHABETICAL_DESC -> R.string.sort_checklist_alpha_desc + ChecklistSortOption.UNCHECKED_FIRST -> R.string.sort_checklist_unchecked_first + ChecklistSortOption.CHECKED_FIRST -> R.string.sort_checklist_checked_first +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/OverflowGradient.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/OverflowGradient.kt new file mode 100644 index 0000000..88b1cfd --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/OverflowGradient.kt @@ -0,0 +1,62 @@ +package dev.dettmer.simplenotes.ui.editor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 🆕 v1.8.0: Dezenter Gradient-Overlay der anzeigt, dass mehr Text + * vorhanden ist als aktuell sichtbar. + * + * Features: + * - Top gradient: surface → transparent (zeigt Text oberhalb) + * - Bottom gradient: transparent → surface (zeigt Text unterhalb) + * - Höhe: 24dp für subtilen, aber erkennbaren Effekt + * - Material You kompatibel: nutzt dynamische surface-Farbe + * - Dark Mode Support: automatisch durch MaterialTheme + * + * Verwendet in: ChecklistItemRow für lange Texteinträge + * + * @param isTopGradient true = Gradient von surface→transparent (oben), false = transparent→surface (unten) + */ +@Composable +fun OverflowGradient( + modifier: Modifier = Modifier, + isTopGradient: Boolean = false +) { + val surfaceColor = MaterialTheme.colorScheme.surface + + val gradientColors = if (isTopGradient) { + // Oben: surface → transparent (zeigt dass Text OBERHALB existiert) + listOf( + surfaceColor.copy(alpha = 0.95f), + surfaceColor.copy(alpha = 0.7f), + Color.Transparent + ) + } else { + // Unten: transparent → surface (zeigt dass Text UNTERHALB existiert) + listOf( + Color.Transparent, + surfaceColor.copy(alpha = 0.7f), + surfaceColor.copy(alpha = 0.95f) + ) + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(GRADIENT_HEIGHT) + .background( + brush = Brush.verticalGradient(colors = gradientColors) + ) + ) +} + +private val GRADIENT_HEIGHT = 24.dp diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt index 40382bf..3ba829c 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt @@ -170,6 +170,9 @@ class ComposeMainActivity : ComponentActivity() { onOpenSettings = { openSettings() }, onCreateNote = { noteType -> createNote(noteType) } ) + + // v1.8.0: Post-Update Changelog (shows once after update) + UpdateChangelogSheet() } } } @@ -219,25 +222,26 @@ class ComposeMainActivity : ComponentActivity() { } private fun setupSyncStateObserver() { + // 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern) SyncStateManager.syncStatus.observe(this) { status -> viewModel.updateSyncState(status) - - @Suppress("MagicNumber") // UI timing delays for banner visibility - // Hide banner after delay for completed/error states - when (status.state) { - SyncStateManager.SyncState.COMPLETED -> { - lifecycleScope.launch { - kotlinx.coroutines.delay(1500L) + } + + // 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System) + lifecycleScope.launch { + SyncStateManager.syncProgress.collect { progress -> + @Suppress("MagicNumber") // UI timing delays for banner visibility + when (progress.phase) { + dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> { + kotlinx.coroutines.delay(2000L) SyncStateManager.reset() } - } - SyncStateManager.SyncState.ERROR -> { - lifecycleScope.launch { - kotlinx.coroutines.delay(3000L) + dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> { + kotlinx.coroutines.delay(4000L) SyncStateManager.reset() } + else -> { /* No action needed */ } } - else -> { /* No action needed */ } } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt index ff9f437..c2561e3 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState @@ -17,6 +18,8 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.automirrored.outlined.Sort import androidx.compose.material3.ExperimentalMaterial3Api // FabPosition nicht mehr benötigt - FAB wird manuell platziert import androidx.compose.material3.Icon @@ -46,13 +49,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.ui.main.components.SortDialog import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import dev.dettmer.simplenotes.ui.main.components.EmptyState import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB import dev.dettmer.simplenotes.ui.main.components.NotesList import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid -import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner +import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner +import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog import kotlinx.coroutines.launch private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L @@ -74,11 +79,13 @@ fun MainScreen( onOpenSettings: () -> Unit, onCreateNote: (NoteType) -> Unit ) { - val notes by viewModel.notes.collectAsState() + val notes by viewModel.sortedNotes.collectAsState() val syncState by viewModel.syncState.collectAsState() - val syncMessage by viewModel.syncMessage.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() @@ -92,6 +99,14 @@ fun MainScreen( // 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() @@ -170,6 +185,9 @@ fun MainScreen( ) { MainTopBar( syncEnabled = canSync, + showSyncLegend = isSyncAvailable, // 🆕 v1.8.0: Nur wenn Sync verfügbar + onSyncLegendClick = { showSyncLegend = true }, // 🆕 v1.8.0 + onSortClick = { showSortDialog = true }, // 🔀 v1.8.0 onSyncClick = { viewModel.triggerManualSync("toolbar") }, onSettingsClick = onOpenSettings ) @@ -190,10 +208,10 @@ fun MainScreen( Box(modifier = Modifier.fillMaxSize()) { // Main content column Column(modifier = Modifier.fillMaxSize()) { - // Sync Status Banner (not affected by pull-to-refresh) - SyncStatusBanner( - syncState = syncState, - message = syncMessage + // 🆕 v1.8.0: Einziges Sync Banner (Progress + Ergebnis) + SyncProgressBanner( + progress = syncProgress, + modifier = Modifier.fillMaxWidth() ) // Content: Empty state or notes list @@ -276,6 +294,28 @@ fun MainScreen( } ) } + + // 🆕 v1.8.0: Sync Status Legend Dialog + if (showSyncLegend) { + SyncStatusLegendDialog( + onDismiss = { showSyncLegend = false } + ) + } + + // 🔀 v1.8.0: Sort Dialog + if (showSortDialog) { + SortDialog( + currentOption = sortOption, + currentDirection = sortDirection, + onOptionSelected = { option -> + viewModel.setSortOption(option) + }, + onDirectionToggled = { + viewModel.toggleSortDirection() + }, + onDismiss = { showSortDialog = false } + ) + } } } @@ -283,6 +323,9 @@ fun MainScreen( @Composable private fun MainTopBar( syncEnabled: Boolean, + showSyncLegend: Boolean, // 🆕 v1.8.0: Ob der Hilfe-Button sichtbar sein soll + onSyncLegendClick: () -> Unit, // 🆕 v1.8.0 + onSortClick: () -> Unit, // 🔀 v1.8.0: Sort-Button onSyncClick: () -> Unit, onSettingsClick: () -> Unit ) { @@ -294,6 +337,23 @@ private fun MainTopBar( ) }, actions = { + // 🔀 v1.8.0: Sort Button + IconButton(onClick = onSortClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Sort, + contentDescription = stringResource(R.string.sort_notes) + ) + } + + // 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar) + if (showSyncLegend) { + IconButton(onClick = onSyncLegendClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = stringResource(R.string.sync_legend_button) + ) + } + } IconButton( onClick = onSyncClick, enabled = syncEnabled diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt index 49a0899..9843eaa 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt @@ -5,8 +5,11 @@ import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.SortDirection +import dev.dettmer.simplenotes.models.SortOption import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.storage.NotesStorage +import dev.dettmer.simplenotes.sync.SyncProgress import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.utils.Constants @@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -102,15 +106,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } // ═══════════════════════════════════════════════════════════════════════ - // Sync State (derived from SyncStateManager) + // 🔀 v1.8.0: Sort State // ═══════════════════════════════════════════════════════════════════════ + private val _sortOption = MutableStateFlow( + SortOption.fromPrefsValue( + prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION + ) + ) + val sortOption: StateFlow = _sortOption.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.asStateFlow() + + /** + * 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection. + * Reagiert automatisch auf Änderungen in allen drei Flows. + */ + val sortedNotes: StateFlow> = combine( + _notes, + _sortOption, + _sortDirection + ) { notes, option, direction -> + sortNotes(notes, option, direction) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + // ═══════════════════════════════════════════════════════════════════════ + // Sync State + // ═══════════════════════════════════════════════════════════════════════ + + // 🆕 v1.8.0: Einziges Banner-System - SyncProgress + val syncProgress: StateFlow = SyncStateManager.syncProgress + + // Intern: SyncState für PullToRefresh-Indikator private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE) val syncState: StateFlow = _syncState.asStateFlow() - private val _syncMessage = MutableStateFlow(null) - val syncMessage: StateFlow = _syncMessage.asStateFlow() - // ═══════════════════════════════════════════════════════════════════════ // UI Events // ═══════════════════════════════════════════════════════════════════════ @@ -495,12 +534,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun updateSyncState(status: SyncStateManager.SyncStatus) { _syncState.value = status.state - _syncMessage.value = status.message } /** * Trigger manual sync (from toolbar button or pull-to-refresh) * v1.7.0: Uses central canSync() gate for WiFi-only check + * v1.8.0: Banner erscheint sofort beim Klick (PREPARING-Phase) */ fun triggerManualSync(source: String = "manual") { // 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) @@ -509,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (!gateResult.canSync) { if (gateResult.isBlockedByWifiOnly) { Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi") - SyncStateManager.markError(getString(R.string.sync_wifi_only_hint)) + SyncStateManager.markError(getString(R.string.sync_wifi_only_error)) } else { Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}") } @@ -517,6 +556,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } // 🆕 v1.7.0: Feedback wenn Sync bereits läuft + // 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant if (!SyncStateManager.tryStartSync(source)) { if (SyncStateManager.isSyncing) { Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress") @@ -533,11 +573,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { try { - // Check for unsynced changes + // Check for unsynced changes (Banner zeigt bereits PREPARING) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ $source Sync: No unsynced changes") - val message = getApplication().getString(R.string.toast_already_synced) - SyncStateManager.markCompleted(message) + SyncStateManager.markCompleted(getString(R.string.toast_already_synced)) loadNotes() return@launch } @@ -559,10 +598,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } if (result.isSuccess) { - val bannerMessage = if (result.syncedCount > 0) { - getString(R.string.toast_sync_success, result.syncedCount) - } else { - getString(R.string.snackbar_nothing_to_sync) + // 🆕 v1.8.0 (IMPL_022): Erweiterte Banner-Nachricht mit Löschungen + val bannerMessage = buildString { + if (result.syncedCount > 0) { + append(getString(R.string.toast_sync_success, result.syncedCount)) + } + if (result.deletedOnServerCount > 0) { + if (isNotEmpty()) append(" · ") + append(getString(R.string.sync_deleted_on_server_count, result.deletedOnServerCount)) + } + if (isEmpty()) { + append(getString(R.string.snackbar_nothing_to_sync)) + } } SyncStateManager.markCompleted(bannerMessage) loadNotes() @@ -606,7 +653,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } - // v1.5.0: silent=true - kein Banner bei Auto-Sync, aber Fehler werden trotzdem angezeigt + // v1.5.0: silent=true → kein Banner bei Auto-Sync + // 🆕 v1.8.0: tryStartSync mit silent=true → SyncProgress.silent=true → Banner unsichtbar if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) { Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") return @@ -622,7 +670,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // Check for unsynced changes if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") - SyncStateManager.reset() + SyncStateManager.reset() // Silent → geht direkt auf IDLE return@launch } @@ -633,7 +681,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (!isReachable) { Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") - SyncStateManager.reset() + SyncStateManager.reset() // Silent → kein Error-Banner return@launch } @@ -644,14 +692,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { 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) 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") - SyncStateManager.markCompleted(getString(R.string.snackbar_nothing_to_sync)) + SyncStateManager.markCompleted() // Silent → geht direkt auf IDLE } else { Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") + // Fehler werden IMMER angezeigt (auch bei Silent-Sync) SyncStateManager.markError(result.errorMessage) } } catch (e: Exception) { @@ -675,6 +725,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return true } + // ═══════════════════════════════════════════════════════════════════════ + // 🔀 v1.8.0: Sortierung + // ═══════════════════════════════════════════════════════════════════════ + + /** + * 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung. + */ + private fun sortNotes( + notes: List, + option: SortOption, + direction: SortDirection + ): List { + val comparator: Comparator = when (option) { + SortOption.UPDATED_AT -> compareBy { it.updatedAt } + SortOption.CREATED_AT -> compareBy { it.createdAt } + SortOption.TITLE -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.title } + SortOption.NOTE_TYPE -> compareBy { it.noteType.ordinal } + .thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen + } + + return when (direction) { + SortDirection.ASCENDING -> notes.sortedWith(comparator) + SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed()) + } + } + + /** + * 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences. + */ + fun setSortOption(option: SortOption) { + _sortOption.value = option + prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply() + Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}") + } + + /** + * 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences. + */ + fun setSortDirection(direction: SortDirection) { + _sortDirection.value = direction + prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply() + Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}") + } + + /** + * 🔀 v1.8.0: Toggelt die Sortierrichtung. + */ + fun toggleSortDirection() { + val newDirection = _sortDirection.value.toggle() + setSortDirection(newDirection) + } + // ═══════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════ diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/UpdateChangelogSheet.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/UpdateChangelogSheet.kt new file mode 100644 index 0000000..429c468 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/UpdateChangelogSheet.kt @@ -0,0 +1,205 @@ +package dev.dettmer.simplenotes.ui.main + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.BuildConfig +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.utils.Constants +import dev.dettmer.simplenotes.utils.Logger +import kotlinx.coroutines.launch + +/** + * v1.8.0: Post-Update Changelog Bottom Sheet + * + * Shows a subtle changelog on first launch after an update. + * - Reads changelog from raw resources (supports DE/EN) + * - Only shows once per versionCode (stored in SharedPreferences) + * - Uses Material 3 ModalBottomSheet with built-in slide-up animation + * - Dismissable via button or swipe-down + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdateChangelogSheet() { + val context = LocalContext.current + val prefs = remember { + context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + } + + val currentVersionCode = BuildConfig.VERSION_CODE + val lastShownVersion = prefs.getInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0) + + // Only show if this is a new version + var showSheet by remember { mutableStateOf(currentVersionCode > lastShownVersion) } + + if (!showSheet) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + // Load changelog text based on current locale + val changelogText = remember { + loadChangelog(context) + } + + ModalBottomSheet( + onDismissRequest = { + showSheet = false + prefs.edit() + .putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode) + .apply() + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Title + Text( + text = stringResource(R.string.update_changelog_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Changelog content with clickable links + val annotatedText = buildAnnotatedString { + val lines = changelogText.split("\n") + lines.forEachIndexed { index, line -> + if (line.startsWith("http://") || line.startsWith("https://")) { + // Make URLs clickable + withLink( + LinkAnnotation.Url( + url = line.trim(), + styles = androidx.compose.ui.text.TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) + ) + ) { + append(line) + } + } else { + append(line) + } + if (index < lines.size - 1) append("\n") + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Dismiss button + Button( + onClick = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + showSheet = false + prefs.edit() + .putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, currentVersionCode) + .apply() + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Text(stringResource(R.string.update_changelog_dismiss)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +/** + * Load changelog text from assets based on current app locale and versionCode. + * Changelogs are copied from /fastlane/metadata/android/{locale}/changelogs/{versionCode}.txt + * at build time, providing a single source of truth for F-Droid and in-app display. + * Falls back to English if the localized version is not available. + */ +private fun loadChangelog(context: Context): String { + val currentLocale = AppCompatDelegate.getApplicationLocales() + val languageCode = if (currentLocale.isEmpty) { + // System default — check system locale + java.util.Locale.getDefault().language + } else { + currentLocale.get(0)?.language ?: "en" + } + + // Map language code to F-Droid locale directory + val localeDir = when (languageCode) { + "de" -> "de-DE" + else -> "en-US" + } + + val versionCode = BuildConfig.VERSION_CODE + val changelogPath = "changelogs/$localeDir/$versionCode.txt" + + return try { + context.assets.open(changelogPath) + .bufferedReader() + .use { it.readText() } + } catch (e: Exception) { + Logger.e("UpdateChangelogSheet", "Failed to load changelog for locale: $localeDir", e) + // Fallback to English + try { + context.assets.open("changelogs/en-US/$versionCode.txt") + .bufferedReader() + .use { it.readText() } + } catch (e2: Exception) { + Logger.e("UpdateChangelogSheet", "Failed to load English fallback changelog", e2) + "v${BuildConfig.VERSION_NAME}" + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt index e6ce88b..1cc0baf 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCard.kt @@ -186,11 +186,19 @@ fun NoteCard( SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff + SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0 + }, + contentDescription = when (note.syncStatus) { + SyncStatus.SYNCED -> stringResource(R.string.sync_status_synced) + SyncStatus.PENDING -> stringResource(R.string.sync_status_pending) + SyncStatus.CONFLICT -> stringResource(R.string.sync_status_conflict) + SyncStatus.LOCAL_ONLY -> stringResource(R.string.sync_status_local_only) + SyncStatus.DELETED_ON_SERVER -> stringResource(R.string.sync_status_deleted_on_server) // 🆕 v1.8.0 }, - contentDescription = null, tint = when (note.syncStatus) { SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error + SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0 else -> MaterialTheme.colorScheme.outline }, modifier = Modifier.size(16.dp) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt index c51ebd8..d04a2f3 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardCompact.kt @@ -187,11 +187,13 @@ fun NoteCardCompact( SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff + SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0 }, contentDescription = null, tint = when (note.syncStatus) { SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error + SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0 else -> MaterialTheme.colorScheme.outline }, modifier = Modifier.size(14.dp) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt index 159be46..606b0e7 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/NoteCardGrid.kt @@ -199,11 +199,13 @@ fun NoteCardGrid( SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff + SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0 }, contentDescription = null, tint = when (note.syncStatus) { SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error + SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0 else -> MaterialTheme.colorScheme.outline }, modifier = Modifier.size(14.dp) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SortDialog.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SortDialog.kt new file mode 100644 index 0000000..e1d6652 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SortDialog.kt @@ -0,0 +1,160 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.SortDirection +import dev.dettmer.simplenotes.models.SortOption + +/** + * 🔀 v1.8.0: Dialog zur Auswahl der Sortierung für die Notizliste. + * + * Zeigt RadioButtons für die Sortieroption und einen Toggle für die Richtung. + * + * ┌─────────────────────────────────┐ + * │ Sort Notes │ + * ├─────────────────────────────────┤ + * │ (●) Last modified ↓↑ │ + * │ ( ) Date created │ + * │ ( ) Name │ + * │ ( ) Type │ + * ├─────────────────────────────────┤ + * │ [Close] │ + * └─────────────────────────────────┘ + */ +@Composable +fun SortDialog( + currentOption: SortOption, + currentDirection: SortDirection, + onOptionSelected: (SortOption) -> Unit, + onDirectionToggled: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.sort_notes), + style = MaterialTheme.typography.headlineSmall + ) + + // Direction Toggle Button + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton(onClick = onDirectionToggled) { + Icon( + imageVector = if (currentDirection == SortDirection.DESCENDING) { + Icons.Default.ArrowDownward + } else { + Icons.Default.ArrowUpward + }, + contentDescription = stringResource( + if (currentDirection == SortDirection.DESCENDING) { + R.string.sort_descending + } else { + R.string.sort_ascending + } + ), + modifier = Modifier.size(24.dp) + ) + } + Text( + text = stringResource( + if (currentDirection == SortDirection.DESCENDING) { + R.string.sort_descending + } else { + R.string.sort_ascending + } + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + text = { + Column { + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + SortOption.entries.forEach { option -> + SortOptionRow( + label = stringResource(option.toStringRes()), + isSelected = currentOption == option, + onClick = { onOptionSelected(option) } + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + } + ) +} + +@Composable +private fun SortOptionRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onClick + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge + ) + } +} + +/** + * Extension: SortOption → String-Resource-ID + */ +fun SortOption.toStringRes(): Int = when (this) { + SortOption.UPDATED_AT -> R.string.sort_by_updated + SortOption.CREATED_AT -> R.string.sort_by_created + SortOption.TITLE -> R.string.sort_by_name + SortOption.NOTE_TYPE -> R.string.sort_by_type +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt new file mode 100644 index 0000000..2f397d8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt @@ -0,0 +1,191 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.sync.SyncPhase +import dev.dettmer.simplenotes.sync.SyncProgress + +/** + * 🆕 v1.8.0: Einziges Sync-Banner für den gesamten Sync-Lebenszyklus + * + * Deckt alle Phasen ab: + * - PREPARING: Indeterminate Spinner + "Synchronisiere…" (sofort beim Klick, bleibt bis echte Arbeit) + * - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen + * - COMPLETED: Erfolgsmeldung mit Checkmark-Icon (auto-hide durch ComposeMainActivity) + * - ERROR: Fehlermeldung mit Error-Icon (auto-hide durch ComposeMainActivity) + * + * Silent Syncs (onResume) zeigen kein Banner (progress.isVisible == false) + */ +@Composable +fun SyncProgressBanner( + progress: SyncProgress, + modifier: Modifier = Modifier +) { + // Farbe animiert wechseln je nach State + val isError = progress.phase == SyncPhase.ERROR + val isCompleted = progress.phase == SyncPhase.COMPLETED + val isResult = isError || isCompleted + + val backgroundColor by animateColorAsState( + targetValue = when { + isError -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.primaryContainer + }, + label = "bannerColor" + ) + + val contentColor by animateColorAsState( + targetValue = when { + isError -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onPrimaryContainer + }, + label = "bannerContentColor" + ) + + AnimatedVisibility( + visible = progress.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier + ) { + Surface( + color = backgroundColor, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + // Zeile 1: Icon + Phase/Message + Counter + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Icon: Spinner (aktiv), Checkmark (completed), Error (error) + when { + isCompleted -> { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor + ) + } + isError -> { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor + ) + } + else -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = contentColor + ) + } + } + + // Text: Ergebnisnachricht oder Phase + Text( + text = when { + isResult && !progress.resultMessage.isNullOrBlank() -> progress.resultMessage + else -> phaseToString(progress.phase) + }, + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + // Counter: x/y bei Uploads (Total bekannt), nur Zähler bei Downloads + if (!isResult && progress.current > 0) { + Text( + text = if (progress.total > 0) { + "${progress.current}/${progress.total}" + } else { + "${progress.current}" + }, + style = MaterialTheme.typography.labelMedium, + color = contentColor.copy(alpha = 0.7f) + ) + } + } + + // Zeile 2: Progress Bar (nur bei Upload mit bekanntem Total) + if (!isResult && progress.total > 0 && progress.current > 0 && + progress.phase == SyncPhase.UPLOADING) { + Spacer(modifier = Modifier.height(8.dp)) + + LinearProgressIndicator( + progress = { progress.progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = contentColor, + trackColor = contentColor.copy(alpha = 0.2f) + ) + } + + // Zeile 3: Aktueller Notiz-Titel (optional, nur bei aktivem Sync) + if (!isResult && !progress.currentFileName.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = progress.currentFileName, + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +/** + * Konvertiert SyncPhase zu lokalisierten String + */ +@Composable +private fun phaseToString(phase: SyncPhase): String { + return when (phase) { + SyncPhase.IDLE -> "" + SyncPhase.PREPARING -> stringResource(R.string.sync_phase_preparing) + SyncPhase.UPLOADING -> stringResource(R.string.sync_phase_uploading) + SyncPhase.DOWNLOADING -> stringResource(R.string.sync_phase_downloading) + SyncPhase.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown) + SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed) + SyncPhase.ERROR -> stringResource(R.string.sync_phase_error) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt index ac9ba26..a5ccc99 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt @@ -5,12 +5,8 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,6 +21,7 @@ import dev.dettmer.simplenotes.sync.SyncStateManager * Sync status banner shown below the toolbar during sync * v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen + * v1.8.0: Nur noch COMPLETED/ERROR States - SYNCING wird von SyncProgressBanner übernommen */ @Composable fun SyncStatusBanner( @@ -32,10 +29,10 @@ fun SyncStatusBanner( message: String?, modifier: Modifier = Modifier ) { - // v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund) - // Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT) - val isVisible = syncState != SyncStateManager.SyncState.IDLE - && syncState != SyncStateManager.SyncState.SYNCING_SILENT + // v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner übernommen) + // IDLE und SYNCING_SILENT werden ignoriert + val isVisible = syncState == SyncStateManager.SyncState.COMPLETED + || syncState == SyncStateManager.SyncState.ERROR AnimatedVisibility( visible = isVisible, @@ -50,23 +47,13 @@ fun SyncStatusBanner( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - if (syncState == SyncStateManager.SyncState.SYNCING) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Spacer(modifier = Modifier.width(12.dp)) + // v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner übernommen Text( text = when (syncState) { - SyncStateManager.SyncState.SYNCING -> stringResource(R.string.sync_status_syncing) - SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false) SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed) SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error) - SyncStateManager.SyncState.IDLE -> "" + else -> "" // SYNCING/IDLE/SYNCING_SILENT nicht mehr relevant }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusLegendDialog.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusLegendDialog.kt new file mode 100644 index 0000000..9db62d8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusLegendDialog.kt @@ -0,0 +1,148 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.CloudDone +import androidx.compose.material.icons.outlined.CloudOff +import androidx.compose.material.icons.outlined.CloudSync +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R + +/** + * 🆕 v1.8.0: Dialog showing the sync status icon legend + * + * Displays all 5 SyncStatus values with their icons, colors, + * and descriptions. Helps users understand what each icon means. + */ +@Composable +fun SyncStatusLegendDialog( + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.sync_legend_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Optional: Kurze Einleitung + Text( + text = stringResource(R.string.sync_legend_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + // ☁️✓ SYNCED + LegendRow( + icon = Icons.Outlined.CloudDone, + tint = MaterialTheme.colorScheme.primary, + label = stringResource(R.string.sync_legend_synced_label), + description = stringResource(R.string.sync_legend_synced_desc) + ) + + // ☁️↻ PENDING + LegendRow( + icon = Icons.Outlined.CloudSync, + tint = MaterialTheme.colorScheme.outline, + label = stringResource(R.string.sync_legend_pending_label), + description = stringResource(R.string.sync_legend_pending_desc) + ) + + // ⚠️ CONFLICT + LegendRow( + icon = Icons.Default.Warning, + tint = MaterialTheme.colorScheme.error, + label = stringResource(R.string.sync_legend_conflict_label), + description = stringResource(R.string.sync_legend_conflict_desc) + ) + + // ☁️✗ LOCAL_ONLY + LegendRow( + icon = Icons.Outlined.CloudOff, + tint = MaterialTheme.colorScheme.outline, + label = stringResource(R.string.sync_legend_local_only_label), + description = stringResource(R.string.sync_legend_local_only_desc) + ) + + // ☁️✗ DELETED_ON_SERVER + LegendRow( + icon = Icons.Outlined.CloudOff, + tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + label = stringResource(R.string.sync_legend_deleted_label), + description = stringResource(R.string.sync_legend_deleted_desc) + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok)) + } + } + ) +} + +/** + * Single row in the sync status legend + * Shows icon + label + description + */ +@Composable +private fun LegendRow( + icon: ImageVector, + tint: Color, + label: String, + description: String +) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, // Dekorativ, Label reicht + tint = tint, + modifier = Modifier + .size(20.dp) + .padding(top = 2.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt index ec9ddbd..bac97b7 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/ComposeSettingsActivity.kt @@ -1,5 +1,6 @@ package dev.dettmer.simplenotes.ui.settings +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.net.Uri @@ -149,7 +150,12 @@ class ComposeSettingsActivity : AppCompatActivity() { /** * Open system battery optimization settings * v1.5.0: Ported from old SettingsActivity + * + * Note: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is acceptable for F-Droid builds. + * For Play Store builds, this would need to be changed to + * ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (shows list, doesn't request directly). */ + @SuppressLint("BatteryLife") private fun openBatteryOptimizationSettings() { try { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) @@ -183,4 +189,16 @@ class ComposeSettingsActivity : AppCompatActivity() { Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}") } } + + /** + * Handle configuration changes (e.g., locale) without recreating activity + * v1.8.0: Prevents flickering during language changes by avoiding full recreate + * Compose automatically recomposes when configuration changes + */ + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + Logger.d(TAG, "📱 Configuration changed (likely locale switch) - Compose will recompose") + // Compose handles UI updates automatically via recomposition + // No manual action needed - stringResource() etc. will pick up new locale + } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt index a714c81..58e4217 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt @@ -14,6 +14,7 @@ import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Logger import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -40,6 +41,8 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application companion object { private const val TAG = "SettingsViewModel" private const val CONNECTION_TIMEOUT_MS = 3000 + private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations + private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important) } private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) @@ -134,7 +137,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES) ) val syncInterval: StateFlow = _syncInterval.asStateFlow() - + + // 🆕 v1.8.0: Max Parallel Downloads + private val _maxParallelDownloads = MutableStateFlow( + prefs.getInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, Constants.DEFAULT_MAX_PARALLEL_DOWNLOADS) + ) + val maxParallelDownloads: StateFlow = _maxParallelDownloads.asStateFlow() + // 🌟 v1.6.0: Configurable Sync Triggers private val _triggerOnSave = MutableStateFlow( prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE) @@ -205,6 +214,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private val _isBackupInProgress = MutableStateFlow(false) val isBackupInProgress: StateFlow = _isBackupInProgress.asStateFlow() + // v1.8.0: Descriptive backup status text + private val _backupStatusText = MutableStateFlow("") + val backupStatusText: StateFlow = _backupStatusText.asStateFlow() + private val _showToast = MutableSharedFlow() val showToast: SharedFlow = _showToast.asSharedFlow() @@ -496,7 +509,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application emitToast(getString(R.string.toast_sync_interval, text)) } } - + + // 🆕 v1.8.0: Max Parallel Downloads Setter + fun setMaxParallelDownloads(count: Int) { + val validCount = count.coerceIn( + Constants.MIN_PARALLEL_DOWNLOADS, + Constants.MAX_PARALLEL_DOWNLOADS + ) + _maxParallelDownloads.value = validCount + prefs.edit().putInt(Constants.KEY_MAX_PARALLEL_DOWNLOADS, validCount).apply() + } + // 🌟 v1.6.0: Configurable Sync Triggers Setters fun setTriggerOnSave(enabled: Boolean) { @@ -655,18 +678,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun createBackup(uri: Uri, password: String? = null) { viewModelScope.launch { _isBackupInProgress.value = true + _backupStatusText.value = getString(R.string.backup_progress_creating) try { val result = backupManager.createBackup(uri, password) - val message = if (result.success) { - getString(R.string.toast_backup_success, result.message ?: "") + + // Phase 2: Show completion status + _backupStatusText.value = if (result.success) { + getString(R.string.backup_progress_complete) } else { - getString(R.string.toast_backup_failed, result.error ?: "") + getString(R.string.backup_progress_failed) } - emitToast(message) + + // Phase 3: Clear after delay + delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) + } catch (e: Exception) { - emitToast(getString(R.string.toast_backup_failed, e.message ?: "")) + Logger.e(TAG, "Failed to create backup", e) + _backupStatusText.value = getString(R.string.backup_progress_failed) + delay(STATUS_CLEAR_DELAY_ERROR_MS) } finally { _isBackupInProgress.value = false + _backupStatusText.value = "" } } } @@ -674,18 +706,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) { viewModelScope.launch { _isBackupInProgress.value = true + _backupStatusText.value = getString(R.string.backup_progress_restoring) try { val result = backupManager.restoreBackup(uri, mode, password) - val message = if (result.success) { - getString(R.string.toast_restore_success, result.importedNotes) + + // Phase 2: Show completion status + _backupStatusText.value = if (result.success) { + getString(R.string.restore_progress_complete) } else { - getString(R.string.toast_restore_failed, result.error ?: "") + getString(R.string.restore_progress_failed) } - emitToast(message) + + // Phase 3: Clear after delay + delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) + } catch (e: Exception) { - emitToast(getString(R.string.toast_restore_failed, e.message ?: "")) + Logger.e(TAG, "Failed to restore backup from file", e) + _backupStatusText.value = getString(R.string.restore_progress_failed) + delay(STATUS_CLEAR_DELAY_ERROR_MS) } finally { _isBackupInProgress.value = false + _backupStatusText.value = "" } } } @@ -716,22 +757,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun restoreFromServer(mode: RestoreMode) { viewModelScope.launch { _isBackupInProgress.value = true + _backupStatusText.value = getString(R.string.backup_progress_restoring_server) try { - emitToast(getString(R.string.restore_progress)) val syncService = WebDavSyncService(getApplication()) val result = withContext(Dispatchers.IO) { syncService.restoreFromServer(mode) } - val message = if (result.isSuccess) { - getString(R.string.toast_restore_success, result.restoredCount) + + // Phase 2: Show completion status + _backupStatusText.value = if (result.isSuccess) { + getString(R.string.restore_server_progress_complete) } else { - getString(R.string.toast_restore_failed, result.errorMessage ?: "") + getString(R.string.restore_server_progress_failed) } - emitToast(message) + + // Phase 3: Clear after delay + delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) + } catch (e: Exception) { - emitToast(getString(R.string.toast_error, e.message ?: "")) + Logger.e(TAG, "Failed to restore from server", e) + _backupStatusText.value = getString(R.string.restore_server_progress_failed) + delay(STATUS_CLEAR_DELAY_ERROR_MS) } finally { _isBackupInProgress.value = false + _backupStatusText.value = "" } } } @@ -762,6 +811,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun getLogFile() = Logger.getLogFile(getApplication()) + /** + * v1.8.0: Reset changelog version to force showing the changelog dialog on next start + * Used for testing the post-update changelog feature + */ + fun resetChangelogVersion() { + prefs.edit() + .putInt(Constants.KEY_LAST_SHOWN_CHANGELOG_VERSION, 0) + .apply() + } + // ═══════════════════════════════════════════════════════════════════════ // Helper // ═══════════════════════════════════════════════════════════════════════ diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt index d9c5532..1b06603 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt @@ -1,9 +1,13 @@ package dev.dettmer.simplenotes.ui.settings.components +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -11,12 +15,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp /** * Primary filled button for settings actions * v1.5.0: Jetpack Compose Settings Redesign + * v1.8.0: Button keeps text during loading, just becomes disabled */ @Composable fun SettingsButton( @@ -31,20 +37,13 @@ fun SettingsButton( enabled = enabled && !isLoading, modifier = modifier.fillMaxWidth() ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text(text) - } + Text(text) } } /** * Outlined secondary button for settings actions + * v1.8.0: Button keeps text during loading, just becomes disabled */ @Composable fun SettingsOutlinedButton( @@ -59,15 +58,7 @@ fun SettingsOutlinedButton( enabled = enabled && !isLoading, modifier = modifier.fillMaxWidth() ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - } else { - Text(text) - } + Text(text) } } @@ -159,3 +150,48 @@ fun SettingsDivider( ) Spacer(modifier = Modifier.height(8.dp)) } + +/** + * v1.8.0: Backup progress indicator shown above buttons + * Replaces the ugly in-button spinner with a clear status display + */ +@Composable +fun BackupProgressCard( + statusText: String, + modifier: Modifier = Modifier +) { + androidx.compose.material3.Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = androidx.compose.material3.CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt index cdd7982..5c97d44 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Policy import androidx.compose.material3.Card @@ -56,6 +57,7 @@ fun AboutScreen( val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync" val githubProfileUrl = "https://github.com/inventory69" val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE" + val changelogUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md" // v1.8.0 SettingsScaffold( title = stringResource(R.string.about_settings_title), @@ -162,6 +164,17 @@ fun AboutScreen( } ) + // v1.8.0: Changelog + AboutLinkItem( + icon = Icons.Default.History, + title = stringResource(R.string.about_changelog_title), + subtitle = stringResource(R.string.about_changelog_subtitle), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(changelogUrl)) + context.startActivity(intent) + } + ) + SettingsDivider() // Data Privacy Info diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt index a748b6d..b2b0b15 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,6 +28,7 @@ import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.backup.RestoreMode import dev.dettmer.simplenotes.ui.settings.SettingsViewModel import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog +import dev.dettmer.simplenotes.ui.settings.components.BackupProgressCard import dev.dettmer.simplenotes.ui.settings.components.RadioOption import dev.dettmer.simplenotes.ui.settings.components.SettingsButton import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider @@ -39,6 +41,10 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.delay + +// v1.8.0: Delay for dialog close animation before starting restore +private const val DIALOG_CLOSE_DELAY_MS = 200L /** * Backup and restore settings screen @@ -60,6 +66,10 @@ fun BackupSettingsScreen( var pendingRestoreUri by remember { mutableStateOf(null) } var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) } + // v1.8.0: Trigger for delayed restore execution (after dialog closes) + var triggerRestore by remember { mutableStateOf(0) } + var pendingRestoreAction by remember { mutableStateOf<(() -> Unit)?>(null) } + // 🔐 v1.7.0: Encryption state var encryptBackup by remember { mutableStateOf(false) } var showEncryptionPasswordDialog by remember { mutableStateOf(false) } @@ -91,6 +101,15 @@ fun BackupSettingsScreen( } } + // v1.8.0: Delayed restore execution after dialog closes + LaunchedEffect(triggerRestore) { + if (triggerRestore > 0) { + delay(DIALOG_CLOSE_DELAY_MS) // Wait for dialog close animation + pendingRestoreAction?.invoke() + pendingRestoreAction = null + } + } + SettingsScaffold( title = stringResource(R.string.backup_settings_title), onBack = onBack @@ -108,6 +127,16 @@ fun BackupSettingsScreen( text = stringResource(R.string.backup_auto_info) ) + // v1.8.0: Progress indicator (visible during backup/restore) + if (isBackupInProgress) { + val backupStatus by viewModel.backupStatusText.collectAsState() + BackupProgressCard( + statusText = backupStatus.ifEmpty { + stringResource(R.string.backup_progress_creating) + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) // Local Backup Section @@ -234,21 +263,29 @@ fun BackupSettingsScreen( when (restoreSource) { RestoreSource.LocalFile -> { pendingRestoreUri?.let { uri -> - // 🔐 v1.7.0: Check if backup is encrypted - viewModel.checkBackupEncryption( - uri = uri, - onEncrypted = { - showDecryptionPasswordDialog = true - }, - onPlaintext = { - viewModel.restoreFromFile(uri, selectedRestoreMode, password = null) - pendingRestoreUri = null - } - ) + // v1.8.0: Schedule restore with delay for dialog close + pendingRestoreAction = { + // 🔐 v1.7.0: Check if backup is encrypted + viewModel.checkBackupEncryption( + uri = uri, + onEncrypted = { + showDecryptionPasswordDialog = true + }, + onPlaintext = { + viewModel.restoreFromFile(uri, selectedRestoreMode, password = null) + pendingRestoreUri = null + } + ) + } + triggerRestore++ } } RestoreSource.Server -> { - viewModel.restoreFromServer(selectedRestoreMode) + // v1.8.0: Schedule restore with delay for dialog close + pendingRestoreAction = { + viewModel.restoreFromServer(selectedRestoreMode) + } + triggerRestore++ } } }, diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt index 99ab408..600ba4b 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt @@ -119,6 +119,31 @@ fun DebugSettingsScreen( ) Spacer(modifier = Modifier.height(16.dp)) + + SettingsDivider() + + // v1.8.0: Test Mode Section + SettingsSectionHeader(text = stringResource(R.string.debug_test_section)) + + Spacer(modifier = Modifier.height(8.dp)) + + // Info about test mode + SettingsInfoCard( + text = stringResource(R.string.debug_reset_changelog_desc) + ) + + val changelogResetToast = stringResource(R.string.debug_changelog_reset) + + SettingsButton( + text = stringResource(R.string.debug_reset_changelog), + onClick = { + viewModel.resetChangelogVersion() + android.widget.Toast.makeText(context, changelogResetToast, android.widget.Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/LanguageSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/LanguageSettingsScreen.kt index f47d9f2..b239b31 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/LanguageSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/LanguageSettingsScreen.kt @@ -1,6 +1,5 @@ package dev.dettmer.simplenotes.ui.settings.screens -import android.app.Activity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -15,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat @@ -35,8 +33,6 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold fun LanguageSettingsScreen( onBack: () -> Unit ) { - val context = LocalContext.current - // Get current app locale - fresh value each time (no remember, always reads current state) val currentLocale = AppCompatDelegate.getApplicationLocales() val currentLanguageCode = if (currentLocale.isEmpty) { @@ -92,7 +88,7 @@ fun LanguageSettingsScreen( onValueSelected = { newLanguage -> if (newLanguage != selectedLanguage) { selectedLanguage = newLanguage - setAppLanguage(newLanguage, context as Activity) + setAppLanguage(newLanguage) } } ) @@ -102,19 +98,19 @@ fun LanguageSettingsScreen( /** * Set app language using AppCompatDelegate - * Works on Android 13+ natively, falls back to AppCompat on older versions + * v1.8.0: Smooth language change without activity recreate + * + * ComposeSettingsActivity handles locale changes via android:configChanges="locale" + * in AndroidManifest.xml, preventing full activity recreate and eliminating flicker. + * Compose automatically recomposes when the configuration changes. */ -private fun setAppLanguage(languageCode: String, activity: Activity) { +private fun setAppLanguage(languageCode: String) { val localeList = if (languageCode.isEmpty()) { LocaleListCompat.getEmptyLocaleList() } else { LocaleListCompat.forLanguageTags(languageCode) } + // Sets the app locale - triggers onConfigurationChanged() instead of recreate() AppCompatDelegate.setApplicationLocales(localeList) - - // Restart the activity to apply the change - // On Android 13+ the system handles this automatically for some apps, - // but we need to recreate to ensure our Compose UI recomposes with new locale - activity.recreate() } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt index d734edf..3d340c0 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt @@ -32,9 +32,11 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch /** - * Sync settings screen - Configurable Sync Triggers - * v1.5.0: Jetpack Compose Settings Redesign - * v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot) + * Sync settings screen — Restructured for v1.8.0 + * + * Two clear sections: + * 1. Sync Triggers (all 5 triggers grouped logically) + * 2. Network & Performance (WiFi-only + Parallel Downloads) */ @Composable fun SyncSettingsScreen( @@ -49,11 +51,10 @@ fun SyncSettingsScreen( val triggerPeriodic by viewModel.triggerPeriodic.collectAsState() val triggerBoot by viewModel.triggerBoot.collectAsState() val syncInterval by viewModel.syncInterval.collectAsState() - - // 🆕 v1.7.0: WiFi-only sync + + val maxParallelDownloads by viewModel.maxParallelDownloads.collectAsState() val wifiOnlySync by viewModel.wifiOnlySync.collectAsState() - // Check if server is configured val isServerConfigured = viewModel.isServerConfigured() SettingsScaffold( @@ -68,7 +69,7 @@ fun SyncSettingsScreen( ) { Spacer(modifier = Modifier.height(8.dp)) - // 🌟 v1.6.0: Offline Mode Warning if server not configured + // ── Offline Mode Warning ── if (!isServerConfigured) { SettingsInfoCard( text = stringResource(R.string.sync_offline_mode_message), @@ -86,37 +87,14 @@ fun SyncSettingsScreen( } // ═══════════════════════════════════════════════════════════════ - // 🆕 v1.7.0: NETZWERK-EINSCHRÄNKUNG Section (Global für alle Trigger) + // SECTION 1: SYNC TRIGGERS // ═══════════════════════════════════════════════════════════════ - SettingsSectionHeader(text = stringResource(R.string.sync_section_network)) - - // WiFi-Only Sync Toggle - Gilt für ALLE Trigger außer WiFi-Connect - SettingsSwitch( - title = stringResource(R.string.sync_wifi_only_title), - subtitle = stringResource(R.string.sync_wifi_only_subtitle), - checked = wifiOnlySync, - onCheckedChange = { viewModel.setWifiOnlySync(it) }, - icon = Icons.Default.Wifi, - enabled = isServerConfigured - ) - - // Info-Hinweis dass WiFi-Connect davon ausgenommen ist - if (wifiOnlySync && isServerConfigured) { - SettingsInfoCard( - text = stringResource(R.string.sync_wifi_only_hint) - ) - } - - SettingsDivider() - - // ═══════════════════════════════════════════════════════════════ - // SOFORT-SYNC Section - // ═══════════════════════════════════════════════════════════════ + SettingsSectionHeader(text = stringResource(R.string.sync_section_triggers)) + // ── Sofort-Sync ── SettingsSectionHeader(text = stringResource(R.string.sync_section_instant)) - // onSave Trigger SettingsSwitch( title = stringResource(R.string.sync_trigger_on_save_title), subtitle = stringResource(R.string.sync_trigger_on_save_subtitle), @@ -126,7 +104,6 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) - // onResume Trigger SettingsSwitch( title = stringResource(R.string.sync_trigger_on_resume_title), subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle), @@ -136,15 +113,11 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) - SettingsDivider() - - // ═══════════════════════════════════════════════════════════════ - // HINTERGRUND-SYNC Section - // ═══════════════════════════════════════════════════════════════ + Spacer(modifier = Modifier.height(4.dp)) + // ── Hintergrund-Sync ── SettingsSectionHeader(text = stringResource(R.string.sync_section_background)) - // WiFi-Connect Trigger SettingsSwitch( title = stringResource(R.string.sync_trigger_wifi_connect_title), subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle), @@ -154,7 +127,6 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) - // Periodic Trigger SettingsSwitch( title = stringResource(R.string.sync_trigger_periodic_title), subtitle = stringResource(R.string.sync_trigger_periodic_subtitle), @@ -164,7 +136,7 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) - // Periodic Interval Selection (only visible if periodic trigger is enabled) + // Interval-Auswahl (nur sichtbar wenn Periodic aktiv) if (triggerPeriodic && isServerConfigured) { Spacer(modifier = Modifier.height(8.dp)) @@ -195,15 +167,6 @@ fun SyncSettingsScreen( Spacer(modifier = Modifier.height(8.dp)) } - SettingsDivider() - - // ═══════════════════════════════════════════════════════════════ - // ADVANCED Section (Boot Sync) - // ═══════════════════════════════════════════════════════════════ - - SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced)) - - // Boot Trigger SettingsSwitch( title = stringResource(R.string.sync_trigger_boot_title), subtitle = stringResource(R.string.sync_trigger_boot_subtitle), @@ -213,9 +176,9 @@ fun SyncSettingsScreen( enabled = isServerConfigured ) - SettingsDivider() + Spacer(modifier = Modifier.height(8.dp)) - // Manual Sync Info + // ── Info Card ── val manualHintText = if (isServerConfigured) { stringResource(R.string.sync_manual_hint) } else { @@ -226,6 +189,68 @@ fun SyncSettingsScreen( text = manualHintText ) + SettingsDivider() + + // ═══════════════════════════════════════════════════════════════ + // SECTION 2: NETZWERK & PERFORMANCE + // ═══════════════════════════════════════════════════════════════ + + SettingsSectionHeader(text = stringResource(R.string.sync_section_network_performance)) + + // WiFi-Only Toggle + SettingsSwitch( + title = stringResource(R.string.sync_wifi_only_title), + subtitle = stringResource(R.string.sync_wifi_only_subtitle), + checked = wifiOnlySync, + onCheckedChange = { viewModel.setWifiOnlySync(it) }, + icon = Icons.Default.Wifi, + enabled = isServerConfigured + ) + + if (wifiOnlySync && isServerConfigured) { + SettingsInfoCard( + text = stringResource(R.string.sync_wifi_only_hint) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Parallel Downloads + val parallelOptions = listOf( + RadioOption( + value = 1, + title = "1 ${stringResource(R.string.sync_parallel_downloads_unit)}", + subtitle = stringResource(R.string.sync_parallel_downloads_desc_1) + ), + RadioOption( + value = 3, + title = "3 ${stringResource(R.string.sync_parallel_downloads_unit)}", + subtitle = stringResource(R.string.sync_parallel_downloads_desc_3) + ), + RadioOption( + value = 5, + title = "5 ${stringResource(R.string.sync_parallel_downloads_unit)}", + subtitle = stringResource(R.string.sync_parallel_downloads_desc_5) + ), + RadioOption( + value = 7, + title = "7 ${stringResource(R.string.sync_parallel_downloads_unit)}", + subtitle = stringResource(R.string.sync_parallel_downloads_desc_7) + ), + RadioOption( + value = 10, + title = "10 ${stringResource(R.string.sync_parallel_downloads_unit)}", + subtitle = stringResource(R.string.sync_parallel_downloads_desc_10) + ) + ) + + SettingsRadioGroup( + title = stringResource(R.string.sync_parallel_downloads_title), + options = parallelOptions, + selectedValue = maxParallelDownloads, + onValueSelected = { viewModel.setMaxParallelDownloads(it) } + ) + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt index 5091b2c..bbd94f2 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -64,7 +64,22 @@ object Constants { // 🎨 v1.7.0: Staggered Grid Layout const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid" - const val DEFAULT_DISPLAY_MODE = "list" + const val DEFAULT_DISPLAY_MODE = "grid" // v1.8.0: Grid als Standard-Ansicht const val GRID_COLUMNS = 2 const val GRID_SPACING_DP = 8 + + // ⚡ v1.8.0: Parallel Downloads + const val KEY_MAX_PARALLEL_DOWNLOADS = "max_parallel_downloads" + const val DEFAULT_MAX_PARALLEL_DOWNLOADS = 5 + const val MIN_PARALLEL_DOWNLOADS = 1 + const val MAX_PARALLEL_DOWNLOADS = 10 + + // 🔀 v1.8.0: Sortierung + const val KEY_SORT_OPTION = "sort_option" + const val KEY_SORT_DIRECTION = "sort_direction" + const val DEFAULT_SORT_OPTION = "updatedAt" + const val DEFAULT_SORT_DIRECTION = "desc" + + // 📋 v1.8.0: Post-Update Changelog + const val KEY_LAST_SHOWN_CHANGELOG_VERSION = "last_shown_changelog_version" } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt new file mode 100644 index 0000000..4c3a2cf --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt @@ -0,0 +1,77 @@ +package dev.dettmer.simplenotes.widget + +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState +import androidx.glance.state.PreferencesGlanceStateDefinition +import dev.dettmer.simplenotes.storage.NotesStorage + +/** + * 🆕 v1.8.0: Homescreen Widget für Notizen und Checklisten + * + * Unterstützt fünf responsive Größen für breite und schmale Layouts: + * - SMALL (110x80dp): Nur Titel + * - NARROW_MEDIUM (110x110dp): Schmal + Vorschau / kompakte Checkliste + * - NARROW_LARGE (110x250dp): Schmal + voller Inhalt + * - WIDE_MEDIUM (250x110dp): Breit + Vorschau + * - WIDE_LARGE (250x250dp): Breit + voller Inhalt / interaktive Checkliste + * + * Features: + * - Material You Dynamic Colors + * - Interaktive Checklist-Checkboxen + * - Sperr-Funktion gegen versehentliches Bearbeiten + * - Tap-to-Edit (öffnet NoteEditor) + * - Einstellbare Hintergrund-Transparenz + * - Permanenter Options-Button (⋮) + * - NoteType-differenzierte Icons + */ +class NoteWidget : GlanceAppWidget() { + + companion object { + // Responsive Breakpoints — schmale + breite Spalten + val SIZE_SMALL = DpSize(110.dp, 80.dp) // Schmal+kurz: nur Titel + val SIZE_NARROW_MEDIUM = DpSize(110.dp, 110.dp) // Schmal+mittel: Vorschau + val SIZE_NARROW_LARGE = DpSize(110.dp, 250.dp) // Schmal+groß: voller Inhalt + val SIZE_WIDE_MEDIUM = DpSize(250.dp, 110.dp) // Breit+mittel: Vorschau + val SIZE_WIDE_LARGE = DpSize(250.dp, 250.dp) // Breit+groß: voller Inhalt + } + + override val sizeMode = SizeMode.Responsive( + setOf(SIZE_SMALL, SIZE_NARROW_MEDIUM, SIZE_NARROW_LARGE, SIZE_WIDE_MEDIUM, SIZE_WIDE_LARGE) + ) + + override val stateDefinition = PreferencesGlanceStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val storage = NotesStorage(context) + + provideContent { + val prefs = currentState() + val noteId = prefs[NoteWidgetState.KEY_NOTE_ID] + val isLocked = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false + val showOptions = prefs[NoteWidgetState.KEY_SHOW_OPTIONS] ?: false + val bgOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f + + val note = noteId?.let { nId -> + storage.loadNote(nId) + } + + GlanceTheme { + NoteWidgetContent( + note = note, + isLocked = isLocked, + showOptions = showOptions, + bgOpacity = bgOpacity, + glanceId = id + ) + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt new file mode 100644 index 0000000..902ffde --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt @@ -0,0 +1,163 @@ +package dev.dettmer.simplenotes.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.state.updateAppWidgetState +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.storage.NotesStorage +import dev.dettmer.simplenotes.utils.Logger + +/** + * 🆕 v1.8.0: ActionParameter Keys für Widget-Interaktionen + * + * Shared Keys für alle ActionCallback-Klassen. + */ +object NoteWidgetActionKeys { + val KEY_NOTE_ID = ActionParameters.Key("noteId") + val KEY_ITEM_ID = ActionParameters.Key("itemId") + val KEY_GLANCE_ID = ActionParameters.Key("glanceId") +} + +/** + * 🐛 FIX: Checklist-Item abhaken/enthaken + * + * Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität. + * + * - Toggelt isChecked im JSON-File + * - Setzt SyncStatus auf PENDING + * - Aktualisiert Widget sofort + */ +class ToggleChecklistItemAction : ActionCallback { + companion object { + private const val TAG = "ToggleChecklistItem" + } + + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val noteId = parameters[NoteWidgetActionKeys.KEY_NOTE_ID] ?: return + val itemId = parameters[NoteWidgetActionKeys.KEY_ITEM_ID] ?: return + + val storage = NotesStorage(context) + val note = storage.loadNote(noteId) ?: return + + val updatedItems = note.checklistItems?.map { item -> + if (item.id == itemId) { + item.copy(isChecked = !item.isChecked) + } else item + } ?: return + + val updatedNote = note.copy( + checklistItems = updatedItems, + updatedAt = System.currentTimeMillis(), + syncStatus = SyncStatus.PENDING + ) + + storage.saveNote(updatedNote) + Logger.d(TAG, "Toggled checklist item '$itemId' in widget") + + // 🐛 FIX: Glance-State ändern um Re-Render zu erzwingen + updateAppWidgetState(context, glanceId) { prefs -> + prefs[NoteWidgetState.KEY_LAST_UPDATED] = System.currentTimeMillis() + } + + // Widget aktualisieren — Glance erkennt jetzt den State-Change + NoteWidget().update(context, glanceId) + } +} + +/** + * 🐛 FIX: Widget sperren/entsperren + * + * Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität. + */ +class ToggleLockAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + updateAppWidgetState(context, glanceId) { prefs -> + val currentLock = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false + prefs[NoteWidgetState.KEY_IS_LOCKED] = !currentLock + // Options ausblenden nach Toggle + prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false + } + + NoteWidget().update(context, glanceId) + } +} + +/** + * 🐛 FIX: Optionsleiste ein-/ausblenden + * + * Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität. + */ +class ShowOptionsAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + updateAppWidgetState(context, glanceId) { prefs -> + val currentShow = prefs[NoteWidgetState.KEY_SHOW_OPTIONS] ?: false + prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = !currentShow + } + + NoteWidget().update(context, glanceId) + } +} + +/** + * 🐛 FIX: Widget-Daten neu laden + * + * Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität. + */ +class RefreshAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + // Options ausblenden + updateAppWidgetState(context, glanceId) { prefs -> + prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false + } + + NoteWidget().update(context, glanceId) + } +} + +/** + * 🆕 v1.8.0: Widget-Konfiguration öffnen (Reconfigure) + * + * Top-Level-Klasse (statt nested) für Class.forName()-Kompatibilität. + * Öffnet die Config-Activity im Reconfigure-Modus. + */ +class OpenConfigAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + // Options ausblenden + updateAppWidgetState(context, glanceId) { prefs -> + prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false + } + + // Config-Activity als Reconfigure öffnen + val glanceManager = androidx.glance.appwidget.GlanceAppWidgetManager(context) + val appWidgetId = glanceManager.getAppWidgetId(glanceId) + + val intent = android.content.Intent(context, NoteWidgetConfigActivity::class.java).apply { + putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + // 🐛 FIX: Eigener Task, damit finish() nicht die MainActivity zeigt + flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK + } + context.startActivity(intent) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt new file mode 100644 index 0000000..33c790a --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt @@ -0,0 +1,159 @@ +package dev.dettmer.simplenotes.widget + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import dev.dettmer.simplenotes.storage.NotesStorage +import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme +import kotlinx.coroutines.launch + +/** + * 🆕 v1.8.0: Konfigurations-Activity beim Platzieren eines Widgets + * + * Zeigt eine Liste aller Notizen. User wählt eine aus, + * die dann im Widget angezeigt wird. + * + * Optionen: + * - Notiz auswählen + * - Widget initial sperren (optional) + * - Hintergrund-Transparenz einstellen + * + * Unterstützt Reconfiguration (Android 12+): Beim erneuten Öffnen + * werden die bestehenden Einstellungen als Defaults geladen. + * + * 🆕 v1.8.0 (IMPL_025): Auto-Save bei Back-Navigation + Save-FAB + */ +class NoteWidgetConfigActivity : ComponentActivity() { + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + // 🆕 v1.8.0 (IMPL_025): State-Tracking für Auto-Save bei Back-Navigation + private var currentSelectedNoteId: String? = null + private var currentLockState: Boolean = false + private var currentOpacity: Float = 1.0f + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Default-Result: Cancelled (falls User zurück-navigiert) + setResult(RESULT_CANCELED) + + // 🆕 v1.8.0 (IMPL_025): Auto-Save bei Back-Navigation + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // Auto-Save nur bei Reconfigure (wenn bereits eine Note konfiguriert war) + if (currentSelectedNoteId != null) { + configureWidget(currentSelectedNoteId!!, currentLockState, currentOpacity) + } else { + finish() + } + } + }) + + // Widget-ID aus Intent + appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + val storage = NotesStorage(this) + + // Bestehende Konfiguration laden (für Reconfigure) + lifecycleScope.launch { + var existingNoteId: String? = null + var existingLock = false + var existingOpacity = 1.0f + + try { + val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity) + .getGlanceIdBy(appWidgetId) + val prefs = getAppWidgetState( + this@NoteWidgetConfigActivity, + PreferencesGlanceStateDefinition, + glanceId + ) + existingNoteId = prefs[NoteWidgetState.KEY_NOTE_ID] + existingLock = prefs[NoteWidgetState.KEY_IS_LOCKED] ?: false + existingOpacity = prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] ?: 1.0f + } catch (_: Exception) { + // Neues Widget — keine bestehende Konfiguration + } + + // 🆕 v1.8.0 (IMPL_025): Initiale State-Werte für Auto-Save setzen + currentSelectedNoteId = existingNoteId + currentLockState = existingLock + currentOpacity = existingOpacity + + setContent { + SimpleNotesTheme { + NoteWidgetConfigScreen( + storage = storage, + initialLock = existingLock, + initialOpacity = existingOpacity, + selectedNoteId = existingNoteId, + onNoteSelected = { noteId, isLocked, opacity -> + configureWidget(noteId, isLocked, opacity) + }, + // 🆕 v1.8.0 (IMPL_025): Save-FAB Callback + onSave = { noteId, isLocked, opacity -> + configureWidget(noteId, isLocked, opacity) + }, + // 🆕 v1.8.0 (IMPL_025): Settings-Änderungen tracken für Auto-Save + onSettingsChanged = { noteId, isLocked, opacity -> + currentSelectedNoteId = noteId + currentLockState = isLocked + currentOpacity = opacity + }, + onCancel = { finish() } + ) + } + } + } + } + + private fun configureWidget(noteId: String, isLocked: Boolean, opacity: Float) { + lifecycleScope.launch { + val glanceId = GlanceAppWidgetManager(this@NoteWidgetConfigActivity) + .getGlanceIdBy(appWidgetId) + + // Widget-State speichern + updateAppWidgetState(this@NoteWidgetConfigActivity, glanceId) { prefs -> + prefs[NoteWidgetState.KEY_NOTE_ID] = noteId + prefs[NoteWidgetState.KEY_IS_LOCKED] = isLocked + prefs[NoteWidgetState.KEY_SHOW_OPTIONS] = false + prefs[NoteWidgetState.KEY_BACKGROUND_OPACITY] = opacity + } + + // Widget initial rendern + NoteWidget().update(this@NoteWidgetConfigActivity, glanceId) + + // Erfolg melden + val resultIntent = Intent().putExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId + ) + setResult(RESULT_OK, resultIntent) + + // 🐛 FIX: Zurück zum Homescreen statt zur MainActivity + // moveTaskToBack() bringt den Task in den Hintergrund → Homescreen wird sichtbar + if (!isTaskRoot) { + finish() + } else { + moveTaskToBack(true) + finish() + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt new file mode 100644 index 0000000..4447515 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt @@ -0,0 +1,272 @@ +package dev.dettmer.simplenotes.widget + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.storage.NotesStorage +import kotlin.math.roundToInt + +/** + * 🆕 v1.8.0: Compose Screen für Widget-Konfiguration + * + * Zeigt alle Notizen als auswählbare Liste. + * Optionen: Widget-Lock, Hintergrund-Transparenz. + * Unterstützt Reconfiguration mit bestehenden Defaults. + * + * 🆕 v1.8.0 (IMPL_025): Save-FAB + onSettingsChanged für Reconfigure-Flow + */ + +private const val NOTE_PREVIEW_MAX_LENGTH = 50 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoteWidgetConfigScreen( + storage: NotesStorage, + initialLock: Boolean = false, + initialOpacity: Float = 1.0f, + selectedNoteId: String? = null, + onNoteSelected: (noteId: String, isLocked: Boolean, opacity: Float) -> Unit, + onSave: ((noteId: String, isLocked: Boolean, opacity: Float) -> Unit)? = null, + onSettingsChanged: ((noteId: String?, isLocked: Boolean, opacity: Float) -> Unit)? = null, + @Suppress("UNUSED_PARAMETER") onCancel: () -> Unit // Reserved for future use +) { + val allNotes = remember { storage.loadAllNotes().sortedByDescending { it.updatedAt } } + var lockWidget by remember { mutableStateOf(initialLock) } + var opacity by remember { mutableFloatStateOf(initialOpacity) } + var currentSelectedId by remember { mutableStateOf(selectedNoteId) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.widget_config_title)) } + ) + }, + floatingActionButton = { + // 🆕 v1.8.0 (IMPL_025): Save-FAB — sichtbar wenn eine Note ausgewählt ist + if (currentSelectedId != null) { + FloatingActionButton( + onClick = { + currentSelectedId?.let { noteId -> + onSave?.invoke(noteId, lockWidget, opacity) + ?: onNoteSelected(noteId, lockWidget, opacity) + } + } + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.widget_config_save) + ) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Lock-Option + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.widget_lock_label), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.widget_lock_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + Switch( + checked = lockWidget, + onCheckedChange = { + lockWidget = it + // 🆕 v1.8.0 (IMPL_025): Settings-Änderung an Activity melden + onSettingsChanged?.invoke(currentSelectedId, lockWidget, opacity) + } + ) + } + + // Opacity-Slider + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.widget_opacity_label), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "${(opacity * 100).roundToInt()}%", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Text( + text = stringResource(R.string.widget_opacity_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(4.dp)) + Slider( + value = opacity, + onValueChange = { + opacity = it + // 🆕 v1.8.0 (IMPL_025): Settings-Änderung an Activity melden + onSettingsChanged?.invoke(currentSelectedId, lockWidget, opacity) + }, + valueRange = 0f..1f, + steps = 9 + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Hinweis + Text( + text = stringResource(R.string.widget_config_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + // Notizen-Liste + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(allNotes, key = { it.id }) { note -> + NoteSelectionCard( + note = note, + isSelected = note.id == currentSelectedId, + onClick = { + currentSelectedId = note.id + // 🐛 FIX: Nur auswählen + Settings-Tracking, NICHT sofort konfigurieren + onSettingsChanged?.invoke(note.id, lockWidget, opacity) + } + ) + } + } + } + } +} + +@Composable +private fun NoteSelectionCard( + note: Note, + isSelected: Boolean = false, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = when (note.noteType) { + NoteType.TEXT -> Icons.Outlined.Description + NoteType.CHECKLIST -> Icons.AutoMirrored.Outlined.List + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.primary + } + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = note.title.ifEmpty { "Untitled" }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1 + ) + Text( + text = when (note.noteType) { + NoteType.TEXT -> note.content.take(NOTE_PREVIEW_MAX_LENGTH).replace("\n", " ") + NoteType.CHECKLIST -> { + val items = note.checklistItems ?: emptyList() + val checked = items.count { it.isChecked } + "✔ $checked/${items.size}" + } + }, + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.outline + }, + maxLines = 1 + ) + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetContent.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetContent.kt new file mode 100644 index 0000000..89eb842 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetContent.kt @@ -0,0 +1,532 @@ +package dev.dettmer.simplenotes.widget + +import android.content.ComponentName +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.actionParametersOf +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.CheckBox +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity + +/** + * 🆕 v1.8.0: Glance Composable Content für das Notiz-Widget + * + * Unterstützt fünf responsive Größenklassen (breit + schmal), + * NoteType-Icons, permanenten Options-Button, und einstellbare Opacity. + */ + +// ── Size Classification ── + +private val WIDGET_HEIGHT_SMALL_THRESHOLD = 110.dp +private val WIDGET_SIZE_MEDIUM_THRESHOLD = 250.dp + +// 🆕 v1.8.0: Increased preview lengths for better text visibility +private const val TEXT_PREVIEW_COMPACT_LENGTH = 120 +private const val TEXT_PREVIEW_FULL_LENGTH = 300 + +private fun DpSize.toSizeClass(): WidgetSizeClass = when { + height < WIDGET_HEIGHT_SMALL_THRESHOLD -> WidgetSizeClass.SMALL + width < WIDGET_SIZE_MEDIUM_THRESHOLD && height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_MED + width < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.NARROW_TALL + height < WIDGET_SIZE_MEDIUM_THRESHOLD -> WidgetSizeClass.WIDE_MED + else -> WidgetSizeClass.WIDE_TALL +} + +@Composable +fun NoteWidgetContent( + note: Note?, + isLocked: Boolean, + showOptions: Boolean, + bgOpacity: Float, + glanceId: GlanceId +) { + val size = LocalSize.current + val context = LocalContext.current + val sizeClass = size.toSizeClass() + + if (note == null) { + EmptyWidgetContent(bgOpacity) + return + } + + // Background mit Opacity + val bgModifier = if (bgOpacity < 1.0f) { + GlanceModifier.background( + ColorProvider( + day = Color.White.copy(alpha = bgOpacity), + night = Color(0xFF1C1B1F).copy(alpha = bgOpacity) + ) + ) + } else { + GlanceModifier.background(GlanceTheme.colors.widgetBackground) + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .cornerRadius(16.dp) + .then(bgModifier) + ) { + Column(modifier = GlanceModifier.fillMaxSize()) { + // 🆕 v1.8.0 (IMPL_025): Offizielle TitleBar mit CircleIconButton (48dp Hit Area) + TitleBar( + startIcon = ImageProvider( + when { + isLocked -> R.drawable.ic_lock + note.noteType == NoteType.CHECKLIST -> R.drawable.ic_widget_checklist + else -> R.drawable.ic_note + } + ), + title = note.title.ifEmpty { "Untitled" }, + iconColor = GlanceTheme.colors.onSurface, + textColor = GlanceTheme.colors.onSurface, + actions = { + CircleIconButton( + imageProvider = ImageProvider(R.drawable.ic_more_vert), + contentDescription = "Options", + backgroundColor = null, // Transparent → nur Icon + 48x48dp Hit Area + contentColor = GlanceTheme.colors.onSurface, + onClick = actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ) + ) + } + ) + + // Optionsleiste (ein-/ausblendbar) + if (showOptions) { + OptionsBar( + isLocked = isLocked, + noteId = note.id, + glanceId = glanceId + ) + } + + // Content-Bereich — Click öffnet Editor (unlocked) oder Options (locked) + val contentClickModifier = GlanceModifier + .fillMaxSize() + .clickable( + onClick = if (!isLocked) { + actionStartActivity( + ComponentName(context, ComposeNoteEditorActivity::class.java), + actionParametersOf( + androidx.glance.action.ActionParameters.Key("extra_note_id") to note.id + ) + ) + } else { + actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ) + } + ) + + // Content — abhängig von SizeClass + when (sizeClass) { + WidgetSizeClass.SMALL -> { + // Nur TitleBar, leerer Body als Click-Target + Box(modifier = contentClickModifier) {} + } + + WidgetSizeClass.NARROW_MED -> Box(modifier = contentClickModifier) { + when (note.noteType) { + NoteType.TEXT -> TextNotePreview(note, compact = true) + NoteType.CHECKLIST -> ChecklistCompactView( + note = note, + maxItems = 2, + isLocked = isLocked, + glanceId = glanceId + ) + } + } + + WidgetSizeClass.NARROW_TALL -> Box(modifier = contentClickModifier) { + when (note.noteType) { + NoteType.TEXT -> TextNoteFullView(note) + NoteType.CHECKLIST -> ChecklistFullView( + note = note, + isLocked = isLocked, + glanceId = glanceId + ) + } + } + + WidgetSizeClass.WIDE_MED -> Box(modifier = contentClickModifier) { + when (note.noteType) { + NoteType.TEXT -> TextNotePreview(note, compact = false) + NoteType.CHECKLIST -> ChecklistCompactView( + note = note, + maxItems = 3, + isLocked = isLocked, + glanceId = glanceId + ) + } + } + + WidgetSizeClass.WIDE_TALL -> Box(modifier = contentClickModifier) { + when (note.noteType) { + NoteType.TEXT -> TextNoteFullView(note) + NoteType.CHECKLIST -> ChecklistFullView( + note = note, + isLocked = isLocked, + glanceId = glanceId + ) + } + } + } + } + } +} + +/** + * Optionsleiste — Lock/Unlock + Refresh + Open in App + */ +@Composable +private fun OptionsBar( + isLocked: Boolean, + noteId: String, + glanceId: GlanceId +) { + val context = LocalContext.current + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + .background(GlanceTheme.colors.secondaryContainer), + horizontalAlignment = Alignment.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Lock/Unlock Toggle + Image( + provider = ImageProvider( + if (isLocked) R.drawable.ic_lock_open else R.drawable.ic_lock + ), + contentDescription = if (isLocked) "Unlock" else "Lock", + modifier = GlanceModifier + .size(36.dp) + .padding(6.dp) + .clickable( + onClick = actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ) + ) + ) + + Spacer(modifier = GlanceModifier.width(4.dp)) + + // Refresh + Image( + provider = ImageProvider(R.drawable.ic_refresh), + contentDescription = "Refresh", + modifier = GlanceModifier + .size(36.dp) + .padding(6.dp) + .clickable( + onClick = actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ) + ) + ) + + Spacer(modifier = GlanceModifier.width(4.dp)) + + // Settings (Reconfigure) + Image( + provider = ImageProvider(R.drawable.ic_settings), + contentDescription = "Settings", + modifier = GlanceModifier + .size(36.dp) + .padding(6.dp) + .clickable( + onClick = actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ) + ) + ) + + Spacer(modifier = GlanceModifier.width(4.dp)) + + // Open in App + Image( + provider = ImageProvider(R.drawable.ic_open_in_new), + contentDescription = "Open", + modifier = GlanceModifier + .size(36.dp) + .padding(6.dp) + .clickable( + onClick = actionStartActivity( + ComponentName(context, ComposeNoteEditorActivity::class.java), + actionParametersOf( + androidx.glance.action.ActionParameters.Key("extra_note_id") to noteId + ) + ) + ) + ) + } +} + +// ── Text Note Views ── + +@Composable +private fun TextNotePreview(note: Note, compact: Boolean) { + Text( + text = note.content.take( + if (compact) TEXT_PREVIEW_COMPACT_LENGTH else TEXT_PREVIEW_FULL_LENGTH + ), + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = if (compact) 13.sp else 14.sp + ), + maxLines = if (compact) 3 else 5, // 🆕 v1.8.0: Increased for better preview + modifier = GlanceModifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) +} + +@Composable +private fun TextNoteFullView(note: Note) { + LazyColumn( + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 12.dp) + ) { + // 🆕 v1.8.0 Fix: Split text into individual lines instead of paragraphs. + // This ensures each line is a separate LazyColumn item that can scroll properly. + // Empty lines are preserved as small spacers for visual paragraph separation. + val lines = note.content.split("\n") + items(lines.size) { index -> + val line = lines[index] + if (line.isBlank()) { + // Preserve empty lines as spacing (paragraph separator) + Spacer(modifier = GlanceModifier.height(8.dp)) + } else { + Text( + text = line, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 14.sp + ), + maxLines = 5, // Allow wrapping but prevent single-item overflow + modifier = GlanceModifier.padding(bottom = 2.dp) + ) + } + } + } +} + +// ── Checklist Views ── + +/** + * Kompakte Checklist-Ansicht für MEDIUM-Größen. + * Zeigt maxItems interaktive Checkboxen + Zusammenfassung. + */ +@Composable +private fun ChecklistCompactView( + note: Note, + maxItems: Int, + isLocked: Boolean, + glanceId: GlanceId +) { + val items = note.checklistItems?.sortedBy { it.order } ?: return + val visibleItems = items.take(maxItems) + val remainingCount = items.size - visibleItems.size + val checkedCount = items.count { it.isChecked } + + Column(modifier = GlanceModifier.padding(horizontal = 8.dp, vertical = 2.dp)) { + visibleItems.forEach { item -> + if (isLocked) { + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (item.isChecked) "✅" else "☐", + style = TextStyle(fontSize = 14.sp) + ) + Spacer(modifier = GlanceModifier.width(6.dp)) + Text( + text = item.text, + style = TextStyle( + color = if (item.isChecked) GlanceTheme.colors.outline + else GlanceTheme.colors.onSurface, + fontSize = 13.sp + ), + maxLines = 1 + ) + } + } else { + CheckBox( + checked = item.isChecked, + onCheckedChange = actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_NOTE_ID to note.id, + NoteWidgetActionKeys.KEY_ITEM_ID to item.id, + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ), + text = item.text, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 13.sp + ), + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 1.dp) + ) + } + } + + if (remainingCount > 0) { + Text( + text = "+$remainingCount more · ✔ $checkedCount/${items.size}", + style = TextStyle( + color = GlanceTheme.colors.outline, + fontSize = 12.sp + ), + modifier = GlanceModifier.padding(top = 2.dp, start = 4.dp) + ) + } + } +} + +/** + * Vollständige Checklist-Ansicht für LARGE-Größen. + */ +@Composable +private fun ChecklistFullView( + note: Note, + isLocked: Boolean, + glanceId: GlanceId +) { + val items = note.checklistItems?.sortedBy { it.order } ?: return + + LazyColumn( + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 8.dp) + ) { + items(items.size) { index -> + val item = items[index] + + if (isLocked) { + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (item.isChecked) "✅" else "☐", + style = TextStyle(fontSize = 16.sp) + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + Text( + text = item.text, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 14.sp + ), + maxLines = 2 + ) + } + } else { + CheckBox( + checked = item.isChecked, + onCheckedChange = actionRunCallback( + actionParametersOf( + NoteWidgetActionKeys.KEY_NOTE_ID to note.id, + NoteWidgetActionKeys.KEY_ITEM_ID to item.id, + NoteWidgetActionKeys.KEY_GLANCE_ID to glanceId.toString() + ) + ), + text = item.text, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 14.sp + ), + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 1.dp) + ) + } + } + } +} + +// ── Empty State ── + +@Composable +private fun EmptyWidgetContent(bgOpacity: Float) { + val bgModifier = if (bgOpacity < 1.0f) { + GlanceModifier.background( + ColorProvider( + day = Color.White.copy(alpha = bgOpacity), + night = Color(0xFF1C1B1F).copy(alpha = bgOpacity) + ) + ) + } else { + GlanceModifier.background(GlanceTheme.colors.widgetBackground) + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .cornerRadius(16.dp) + .then(bgModifier) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Note not found", + style = TextStyle( + color = GlanceTheme.colors.outline, + fontSize = 14.sp + ) + ) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetReceiver.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetReceiver.kt new file mode 100644 index 0000000..ad2bba9 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetReceiver.kt @@ -0,0 +1,13 @@ +package dev.dettmer.simplenotes.widget + +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +/** + * 🆕 v1.8.0: BroadcastReceiver für das Notiz-Widget + * + * Muss im AndroidManifest.xml registriert werden. + * Delegiert alle Widget-Updates an NoteWidget. + */ +class NoteWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: NoteWidget = NoteWidget() +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetState.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetState.kt new file mode 100644 index 0000000..17066d7 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetState.kt @@ -0,0 +1,29 @@ +package dev.dettmer.simplenotes.widget + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +/** + * 🆕 v1.8.0: Widget-State Keys (per Widget-Instance) + * + * Gespeichert via PreferencesGlanceStateDefinition (DataStore). + * Jede Widget-Instanz hat eigene Preferences. + */ +object NoteWidgetState { + /** ID der angezeigten Notiz */ + val KEY_NOTE_ID = stringPreferencesKey("widget_note_id") + + /** Ob das Widget gesperrt ist (keine Bearbeitung möglich) */ + val KEY_IS_LOCKED = booleanPreferencesKey("widget_is_locked") + + /** Ob die Optionsleiste angezeigt wird */ + val KEY_SHOW_OPTIONS = booleanPreferencesKey("widget_show_options") + + /** Hintergrund-Transparenz (0.0 = vollständig transparent, 1.0 = opak) */ + val KEY_BACKGROUND_OPACITY = floatPreferencesKey("widget_bg_opacity") + + /** Timestamp des letzten Updates — erzwingt Widget-Recomposition */ + val KEY_LAST_UPDATED = longPreferencesKey("widget_last_updated") +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/widget/WidgetSizeClass.kt b/android/app/src/main/java/dev/dettmer/simplenotes/widget/WidgetSizeClass.kt new file mode 100644 index 0000000..872040d --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/WidgetSizeClass.kt @@ -0,0 +1,14 @@ +package dev.dettmer.simplenotes.widget + +/** + * 🆕 v1.8.0: Size classification for responsive Note Widget layouts + * + * Determines which layout variant to use based on widget dimensions. + */ +enum class WidgetSizeClass { + SMALL, // Nur Titel + NARROW_MED, // Schmal, Vorschau + NARROW_TALL, // Schmal, voller Inhalt + WIDE_MED, // Breit, Vorschau + WIDE_TALL // Breit, voller Inhalt +} diff --git a/android/app/src/main/res/drawable/ic_lock.xml b/android/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..a34ed63 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_lock_open.xml b/android/app/src/main/res/drawable/ic_lock_open.xml new file mode 100644 index 0000000..206870b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_lock_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_more_vert.xml b/android/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..d903048 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_note.xml b/android/app/src/main/res/drawable/ic_note.xml new file mode 100644 index 0000000..49088d1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_note.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_open_in_new.xml b/android/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000..95ccf93 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_refresh.xml b/android/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..d7f83f4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..2466fff --- /dev/null +++ b/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_checklist.xml b/android/app/src/main/res/drawable/ic_widget_checklist.xml new file mode 100644 index 0000000..74174ed --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_checklist.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/widget_preview_background.xml b/android/app/src/main/res/drawable/widget_preview_background.xml new file mode 100644 index 0000000..3d73e83 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_preview_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 77ca364..3cc599e 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -56,7 +56,7 @@ @@ -72,7 +72,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="🏠 Intern (HTTP)" + android:text="@string/server_connection_http" android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:checked="false" /> @@ -81,7 +81,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="🌐 Extern (HTTPS)" + android:text="@string/server_connection_https" android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:checked="true" /> @@ -92,7 +92,7 @@ android:id="@+id/protocolHintText" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)" + android:text="@string/server_connection_http_hint" android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:textColor="?attr/colorOnSurfaceVariant" android:layout_marginBottom="16dp" @@ -104,12 +104,12 @@ android:id="@+id/textInputLayoutServerUrl" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="Server-Adresse" + android:hint="@string/server_address" android:layout_marginBottom="12dp" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" app:startIconDrawable="@android:drawable/ic_menu_compass" app:endIconMode="clear_text" - app:helperText="z.B. http://192.168.0.188:8080/notes" + app:helperText="@string/server_address_hint" app:helperTextEnabled="true" app:boxCornerRadiusTopStart="12dp" app:boxCornerRadiusTopEnd="12dp" @@ -298,7 +298,7 @@ @@ -315,7 +315,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" - android:text="Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱️ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps." + android:text="@string/sync_interval_info" android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:lineSpacingMultiplier="1.3" /> @@ -333,14 +333,14 @@ android:id="@+id/radioInterval15" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="⚡ Alle 15 Minuten" + android:text="@string/sync_interval_15min_title" android:textAppearance="@style/TextAppearance.Material3.BodyLarge" android:paddingVertical="8dp" /> @@ -405,7 +405,7 @@ @@ -422,7 +422,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" - android:text="ℹ️ Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format." + android:text="@string/markdown_info" android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:textColor="?attr/colorOnPrimaryContainer" android:lineSpacingMultiplier="1.3" /> @@ -441,7 +441,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="🔄 Markdown Auto-Sync" + android:text="@string/markdown_auto_sync_title" android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> @@ -468,7 +468,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:layout_marginBottom="8dp" - android:text="Oder synchronisiere Markdown-Dateien manuell:" + android:text="@string/settings_markdown_manual_hint" android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textColor="?attr/colorOnSurface" android:visibility="gone" /> @@ -478,7 +478,7 @@ android:id="@+id/buttonManualMarkdownSync" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Markdown synchronisieren" + android:text="@string/settings_markdown_manual_button" android:visibility="gone" style="@style/Widget.Material3.Button.TonalButton" /> @@ -521,7 +521,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" - android:text="ℹ️ Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt." + android:text="@string/settings_backup_info" android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:textColor="?attr/colorOnPrimaryContainer" android:lineSpacingMultiplier="1.3" /> @@ -532,7 +532,7 @@ @@ -541,7 +541,7 @@ android:id="@+id/buttonCreateBackup" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="📥 Backup erstellen" + android:text="@string/backup_create" android:layout_marginBottom="8dp" style="@style/Widget.Material3.Button.TonalButton" /> @@ -550,7 +550,7 @@ android:id="@+id/buttonRestoreFromFile" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="📤 Aus Datei wiederherstellen" + android:text="@string/backup_restore_file" android:layout_marginBottom="16dp" style="@style/Widget.Material3.Button.TonalButton" /> @@ -566,7 +566,7 @@ @@ -575,7 +575,7 @@ android:id="@+id/buttonRestoreFromServer" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="🔄 Vom Server wiederherstellen" + android:text="@string/backup_restore_server" style="@style/Widget.Material3.Button.TonalButton" /> @@ -600,7 +600,7 @@ @@ -622,7 +622,7 @@ @@ -631,7 +631,7 @@ android:id="@+id/textViewAppVersion" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Version wird geladen..." + android:text="@string/settings_about_app_version_loading" android:textAppearance="@style/TextAppearance.Material3.BodyMedium" /> @@ -659,7 +659,7 @@ @@ -667,7 +667,7 @@ @@ -695,7 +695,7 @@ @@ -703,7 +703,7 @@ @@ -730,7 +730,7 @@ @@ -738,7 +738,7 @@ @@ -767,7 +767,7 @@ @@ -796,7 +796,7 @@ @@ -804,7 +804,7 @@ @@ -834,7 +834,7 @@ android:id="@+id/buttonExportLogs" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="📤 Logs exportieren & teilen" + android:text="@string/settings_debug_export_logs" style="@style/Widget.Material3.Button.TonalButton" android:layout_marginBottom="8dp" /> @@ -843,7 +843,7 @@ android:id="@+id/buttonClearLogs" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="🗑️ Logs löschen" + android:text="@string/settings_debug_delete_logs" style="@style/Widget.Material3.Button.OutlinedButton" /> diff --git a/android/app/src/main/res/layout/widget_preview.xml b/android/app/src/main/res/layout/widget_preview.xml new file mode 100644 index 0000000..d317b4d --- /dev/null +++ b/android/app/src/main/res/layout/widget_preview.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index f773ecd..4ca3b2a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -58,6 +58,60 @@ Synchronisierung fehlgeschlagen Synchronisierung läuft bereits + + Mit Server synchronisiert + Warte auf Synchronisierung + Synchronisierungskonflikt erkannt + Noch nicht synchronisiert + Auf Server gelöscht + + + Sync-Status Hilfe + Sync-Status Icons + Jede Notiz zeigt ein kleines Icon, das den Sync-Status anzeigt: + Synchronisiert + Diese Notiz ist auf allen Geräten aktuell. + Ausstehend + Diese Notiz hat lokale Änderungen, die noch synchronisiert werden müssen. + Konflikt + Diese Notiz wurde auf mehreren Geräten gleichzeitig geändert. Die neueste Version wurde beibehalten. + Nur lokal + Diese Notiz wurde noch nie mit dem Server synchronisiert. + Auf Server gelöscht + Diese Notiz wurde auf einem anderen Gerät oder direkt auf dem Server gelöscht. Sie existiert noch lokal. + + + %d auf Server gelöscht + + + Synchronisiere… + Prüfe Server… + Hochladen… + Herunterladen… + Markdown importieren… + Speichern… + Sync abgeschlossen + Sync fehlgeschlagen + + + Notizen sortieren + Aufsteigend + Absteigend + Zuletzt bearbeitet + Erstelldatum + Name + Typ + Schließen + + + Checkliste sortieren + Manuell + A → Z + Z → A + Unerledigte zuerst + Erledigte zuerst + Anwenden + @@ -162,12 +216,26 @@ Markdown Desktop-Integration Auto-Sync: An Auto-Sync: Aus + Oder synchronisiere Markdown-Dateien manuell: + Markdown synchronisieren Backup & Wiederherstellung Lokales oder Server-Backup + 📦 Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt. + Lokales Backup + Server-Backup Über diese App + 📱 App-Version + Version wird geladen… + 🌐 GitHub Repository + 👤 Entwickler + ⚖️ Lizenz Debug & Diagnose Logging: An Logging: Aus + 📝 Datei-Logging + Sync-Logs in Datei speichern + 📤 Logs exportieren & teilen + 🗑️ Logs löschen @@ -223,7 +291,12 @@ 📡 Hintergrund-Sync ⚙️ Erweitert + + Sync-Auslöser + Netzwerk & Performance + 💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird. + Sync funktioniert nur wenn WLAN verbunden ist Nach dem Speichern Sync sofort nach jeder Änderung @@ -330,6 +403,10 @@ 🗑️ Logs löschen Logs löschen? Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht. + Test-Modus + Changelog-Dialog zurücksetzen + Changelog beim nächsten App-Start anzeigen + Changelog wird beim nächsten Start angezeigt ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren. @@ -340,7 +417,7 @@ Systemstandard English Deutsch - ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden. + ℹ️ Wähle deine bevorzugte Sprache. Die Ansicht wird kurz aktualisiert, um die Änderung anzuwenden. Sprache geändert. Neustart… @@ -350,7 +427,7 @@ Notizen-Ansicht 📋 Listen-Ansicht 🎨 Raster-Ansicht - Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein. + Die Raster-Ansicht zeigt Notizen in zwei Spalten. Alle Notizen erscheinen nebeneinander in einer kompakten Übersicht. @@ -365,6 +442,30 @@ GitHub Profil: @inventory69 Lizenz MIT License - Open Source + + + Changelog + Vollständige Versionshistorie + + + Was ist neu + Alles klar! + + + Backup wird erstellt… + Backup wird wiederhergestellt… + Notizen werden vom Server heruntergeladen… + + + Backup erstellt! + Wiederherstellung abgeschlossen! + Download abgeschlossen! + + + Backup fehlgeschlagen + Wiederherstellung fehlgeschlagen + Download fehlgeschlagen + 🔒 Datenschutz Diese App sammelt keine Daten. Alle Notizen werden nur lokal auf deinem Gerät und auf deinem eigenen WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung. @@ -476,4 +577,37 @@ %d Notiz synchronisiert %d Notizen synchronisiert + + + + %d erledigt + %d erledigt + + + + + + Parallele Downloads + parallel + Sequentiell (langsam, sicher) + Ausgewogen (3x schneller) + Empfohlen (5x schneller) + Schnell (7x schneller) + Maximum (10x schneller, kann Server belasten) + + + + + Simple Notes + Zeige eine Notiz oder Checkliste auf dem Startbildschirm + Notiz auswählen + Tippe auf eine Notiz, um sie als Widget hinzuzufügen + Speichern + Widget sperren + Versehentliches Bearbeiten verhindern + Notiz nicht gefunden + Hintergrund-Transparenz + Transparenz des Widget-Hintergrunds anpassen + Einkaufsliste + Milch, Eier, Brot, Butter, Käse, Tomaten, Nudeln, Reis, Olivenöl… diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e51bbf0..fa42f2b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -58,6 +58,60 @@ Sync failed Sync already in progress + + Synced with server + Waiting for sync + Sync conflict detected + Not yet synced + Deleted on server + + + Sync status help + Sync Status Icons + Each note shows a small icon indicating its sync status: + Synced + This note is up to date on all devices. + Pending + This note has local changes waiting to be synced. + Conflict + This note was changed on multiple devices simultaneously. The latest version was kept. + Local only + This note has never been synced to the server yet. + Deleted on server + This note was deleted on another device or directly on the server. It still exists locally. + + + %d deleted on server + + + Synchronizing… + Checking server… + Uploading… + Downloading… + + + Sort notes + Ascending + Descending + Last modified + Date created + Name + Type + Close + + + Sort checklist + Manual + A → Z + Z → A + Unchecked first + Checked first + Apply + Importing Markdown… + Saving… + Sync complete + Sync failed + @@ -162,12 +216,26 @@ Markdown Desktop Integration Auto-Sync: On Auto-Sync: Off + Or sync markdown files manually: + Sync Markdown Backup & Restore Local or server backup + 📦 A safety backup is automatically created before each restore. + Local Backup + Server Backup About this App + 📱 App Version + Loading version… + 🌐 GitHub Repository + 👤 Developer + ⚖️ License Debug & Diagnostics Logging: On Logging: Off + 📝 File Logging + Save sync logs to file + 📤 Export & share logs + 🗑️ Delete logs @@ -223,7 +291,12 @@ 📡 Background Sync ⚙️ Advanced + + Sync Triggers + Network & Performance + 💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected. + Sync only works when WiFi is connected After Saving Sync immediately after each change @@ -330,6 +403,10 @@ 🗑️ Delete Logs Delete logs? All saved sync logs will be permanently deleted. + Test Mode + Reset Changelog Dialog + Show changelog on next app start + Changelog will show on next start ℹ️ Privacy: Logs are only stored locally on your device and are never sent to external servers. Logs contain sync activities for troubleshooting. You can delete or export them at any time. @@ -340,7 +417,7 @@ System Default English Deutsch - ℹ️ Choose your preferred language. The app will restart to apply the change. + ℹ️ Choose your preferred language. The view will briefly refresh to apply the change. Language changed. Restarting… @@ -350,7 +427,7 @@ Note Display Mode 📋 List View 🎨 Grid View - Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width. + Grid view shows notes in a two-column layout. All notes appear side-by-side in a clean, compact overview. @@ -365,6 +442,30 @@ GitHub Profile: @inventory69 License MIT License - Open Source + + + Changelog + Full version history + + + What\'s New + Got it! + + + Creating backup… + Restoring backup… + Downloading notes from server… + + + Backup created! + Restore complete! + Download complete! + + + Backup failed + Restore failed + Download failed + 🔒 Privacy This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads. @@ -476,4 +577,38 @@ %d note synced %d notes synced + + + + %d completed + %d completed + + + + + + Parallel Downloads + parallel + Sequential (slowest, safest) + Balanced (3x faster) + Recommended (5x faster) + Fast (7x faster) + Maximum (10x faster, may stress server) + + + + + Simple Notes + Display a note or checklist on your home screen + Choose a Note + Tap a note to add it as a widget + Save + Lock widget + Prevent accidental edits + Note not found + Background opacity + Adjust the transparency of the widget background + Shopping List + Milk, eggs, bread, butter, cheese, tomatoes, pasta, rice, olive oil… + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index b69e5da..05a66a9 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,18 +1,31 @@ - - - - + Android's Network Security Config doesn't support IP-based domain rules, + so we must allow cleartext globally but validate URLs in the app. + Public servers MUST use HTTPS. --> + - - + + + + + + + + diff --git a/android/app/src/main/res/xml/note_widget_info.xml b/android/app/src/main/res/xml/note_widget_info.xml new file mode 100644 index 0000000..de108c0 --- /dev/null +++ b/android/app/src/main/res/xml/note_widget_info.xml @@ -0,0 +1,20 @@ + + + diff --git a/android/app/src/test/java/dev/dettmer/simplenotes/sync/parallel/ParallelDownloadTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/sync/parallel/ParallelDownloadTest.kt new file mode 100644 index 0000000..cc59cc2 --- /dev/null +++ b/android/app/src/test/java/dev/dettmer/simplenotes/sync/parallel/ParallelDownloadTest.kt @@ -0,0 +1,114 @@ +package dev.dettmer.simplenotes.sync.parallel + +import org.junit.Assert.* +import org.junit.Test + +/** + * 🆕 v1.8.0: Unit tests for IMPL_005 - Parallel Downloads + * + * These tests validate the basic functionality of parallel downloads: + * - DownloadTask data class creation + * - DownloadTaskResult sealed class variants + * - ParallelDownloader constants + * + * Note: Full integration tests with mocked Sardine would require MockK/Mockito, + * which are not currently in the project dependencies. + */ +class ParallelDownloadTest { + + // Note: DownloadTask tests require mocking DavResource, skipping for now + // Full integration tests would require MockK or Mockito + + @Test + fun `DownloadTaskResult Success contains correct data`() { + val result = DownloadTaskResult.Success( + noteId = "note-1", + content = "{\"id\":\"note-1\"}", + etag = "etag123" + ) + + assertEquals("note-1", result.noteId) + assertEquals("{\"id\":\"note-1\"}", result.content) + assertEquals("etag123", result.etag) + } + + @Test + fun `DownloadTaskResult Failure contains error`() { + val error = Exception("Network error") + val result = DownloadTaskResult.Failure( + noteId = "note-2", + error = error + ) + + assertEquals("note-2", result.noteId) + assertEquals("Network error", result.error.message) + } + + @Test + fun `DownloadTaskResult Skipped contains reason`() { + val result = DownloadTaskResult.Skipped( + noteId = "note-3", + reason = "Already up to date" + ) + + assertEquals("note-3", result.noteId) + assertEquals("Already up to date", result.reason) + } + + @Test + fun `ParallelDownloader has correct default constants`() { + assertEquals(5, ParallelDownloader.DEFAULT_MAX_PARALLEL) + assertEquals(2, ParallelDownloader.DEFAULT_RETRY_COUNT) + } + + @Test + fun `ParallelDownloader constants are in valid range`() { + // Verify default values are within our configured range + assertTrue( + "Default parallel downloads should be >= 1", + ParallelDownloader.DEFAULT_MAX_PARALLEL >= 1 + ) + assertTrue( + "Default parallel downloads should be <= 10", + ParallelDownloader.DEFAULT_MAX_PARALLEL <= 10 + ) + assertTrue( + "Default retry count should be >= 0", + ParallelDownloader.DEFAULT_RETRY_COUNT >= 0 + ) + } + + @Test + fun `DownloadTaskResult types are distinguishable`() { + val success: DownloadTaskResult = DownloadTaskResult.Success("id1", "content", "etag") + val failure: DownloadTaskResult = DownloadTaskResult.Failure("id2", Exception()) + val skipped: DownloadTaskResult = DownloadTaskResult.Skipped("id3", "reason") + + assertTrue("Success should be instance of Success", success is DownloadTaskResult.Success) + assertTrue("Failure should be instance of Failure", failure is DownloadTaskResult.Failure) + assertTrue("Skipped should be instance of Skipped", skipped is DownloadTaskResult.Skipped) + + assertFalse("Success should not be Failure", success is DownloadTaskResult.Failure) + assertFalse("Failure should not be Skipped", failure is DownloadTaskResult.Skipped) + assertFalse("Skipped should not be Success", skipped is DownloadTaskResult.Success) + } + + @Test + fun `DownloadTaskResult when expression works correctly`() { + val results = listOf( + DownloadTaskResult.Success("id1", "content", "etag"), + DownloadTaskResult.Failure("id2", Exception("error")), + DownloadTaskResult.Skipped("id3", "reason") + ) + + val types = results.map { result -> + when (result) { + is DownloadTaskResult.Success -> "success" + is DownloadTaskResult.Failure -> "failure" + is DownloadTaskResult.Skipped -> "skipped" + } + } + + assertEquals(listOf("success", "failure", "skipped"), types) + } +} diff --git a/android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt new file mode 100644 index 0000000..768586f --- /dev/null +++ b/android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt @@ -0,0 +1,177 @@ +package dev.dettmer.simplenotes.ui.editor + +import org.junit.Assert.* +import org.junit.Test + +/** + * 🆕 v1.8.0 (IMPL_017): Unit Tests für Checklisten-Sortierung + * + * Validiert die Auto-Sort Funktionalität: + * - Unchecked items erscheinen vor checked items + * - Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten (stabile Sortierung) + * - Order-Werte werden korrekt neu zugewiesen + */ +class ChecklistSortingTest { + + /** + * Helper function to create a test ChecklistItemState + */ + private fun item(id: String, checked: Boolean, order: Int): ChecklistItemState { + return ChecklistItemState( + id = id, + text = "Item $id", + isChecked = checked, + order = order + ) + } + + /** + * Simulates the sortChecklistItems() function from NoteEditorViewModel + * (Since it's private, we test the logic here) + */ + private fun sortChecklistItems(items: List): List { + val unchecked = items.filter { !it.isChecked } + val checked = items.filter { it.isChecked } + + return (unchecked + checked).mapIndexed { index, item -> + item.copy(order = index) + } + } + + @Test + fun `unchecked items appear before checked items`() { + val items = listOf( + item("a", checked = true, order = 0), + item("b", checked = false, order = 1), + item("c", checked = true, order = 2), + item("d", checked = false, order = 3) + ) + + val sorted = sortChecklistItems(items) + + assertFalse("First item should be unchecked", sorted[0].isChecked) // b + assertFalse("Second item should be unchecked", sorted[1].isChecked) // d + assertTrue("Third item should be checked", sorted[2].isChecked) // a + assertTrue("Fourth item should be checked", sorted[3].isChecked) // c + } + + @Test + fun `relative order within groups is preserved (stable sort)`() { + val items = listOf( + item("first-checked", checked = true, order = 0), + item("first-unchecked", checked = false, order = 1), + item("second-checked", checked = true, order = 2), + item("second-unchecked",checked = false, order = 3) + ) + + val sorted = sortChecklistItems(items) + + assertEquals("first-unchecked", sorted[0].id) + assertEquals("second-unchecked", sorted[1].id) + assertEquals("first-checked", sorted[2].id) + assertEquals("second-checked", sorted[3].id) + } + + @Test + fun `all unchecked - no change needed`() { + val items = listOf( + item("a", checked = false, order = 0), + item("b", checked = false, order = 1) + ) + + val sorted = sortChecklistItems(items) + + assertEquals("a", sorted[0].id) + assertEquals("b", sorted[1].id) + } + + @Test + fun `all checked - no change needed`() { + val items = listOf( + item("a", checked = true, order = 0), + item("b", checked = true, order = 1) + ) + + val sorted = sortChecklistItems(items) + + assertEquals("a", sorted[0].id) + assertEquals("b", sorted[1].id) + } + + @Test + fun `order values are reassigned after sort`() { + val items = listOf( + item("a", checked = true, order = 0), + item("b", checked = false, order = 1) + ) + + val sorted = sortChecklistItems(items) + + assertEquals(0, sorted[0].order) // b → order 0 + assertEquals(1, sorted[1].order) // a → order 1 + } + + @Test + fun `empty list returns empty list`() { + val items = emptyList() + val sorted = sortChecklistItems(items) + assertTrue("Empty list should remain empty", sorted.isEmpty()) + } + + @Test + fun `single item list returns unchanged`() { + val items = listOf(item("a", checked = false, order = 0)) + val sorted = sortChecklistItems(items) + + assertEquals(1, sorted.size) + assertEquals("a", sorted[0].id) + assertEquals(0, sorted[0].order) + } + + @Test + fun `mixed list with multiple items maintains correct grouping`() { + val items = listOf( + item("1", checked = false, order = 0), + item("2", checked = true, order = 1), + item("3", checked = false, order = 2), + item("4", checked = true, order = 3), + item("5", checked = false, order = 4) + ) + + val sorted = sortChecklistItems(items) + + // First 3 should be unchecked + assertFalse(sorted[0].isChecked) + assertFalse(sorted[1].isChecked) + assertFalse(sorted[2].isChecked) + + // Last 2 should be checked + assertTrue(sorted[3].isChecked) + assertTrue(sorted[4].isChecked) + + // Verify order within unchecked group (1, 3, 5) + assertEquals("1", sorted[0].id) + assertEquals("3", sorted[1].id) + assertEquals("5", sorted[2].id) + + // Verify order within checked group (2, 4) + assertEquals("2", sorted[3].id) + assertEquals("4", sorted[4].id) + } + + @Test + fun `orders are sequential after sorting`() { + val items = listOf( + item("a", checked = true, order = 10), + item("b", checked = false, order = 5), + item("c", checked = false, order = 20) + ) + + val sorted = sortChecklistItems(items) + + // Orders should be 0, 1, 2 regardless of input + assertEquals(0, sorted[0].order) + assertEquals(1, sorted[1].order) + assertEquals(2, sorted[2].order) + } +} diff --git a/fastlane/metadata/android/de-DE/changelogs/20.txt b/fastlane/metadata/android/de-DE/changelogs/20.txt new file mode 100644 index 0000000..5c69d4f --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/20.txt @@ -0,0 +1,11 @@ +🎉 v1.8.0 — WIDGETS & UI-VERBESSERUNGEN + +• Neu: Homescreen-Widgets mit interaktiven Checkboxen +• Neu: Widget-Transparenz & Sperr-Einstellungen +• Neu: Notiz-Sortierung (Datum, Titel, Typ) +• Neu: Parallele Downloads (1-10 gleichzeitig) +• Verbessert: Raster-Standard, Sync-Struktur, Live-Fortschritt +• Weitere UI/UX-Verbesserungen + +Vollständiger Changelog: +https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.de.md diff --git a/fastlane/metadata/android/en-US/changelogs/20.txt b/fastlane/metadata/android/en-US/changelogs/20.txt new file mode 100644 index 0000000..faac6f1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20.txt @@ -0,0 +1,13 @@ +🎉 v1.8.0 — WIDGETS & UI POLISH + +• New: Home screen widgets with interactive checkboxes +• New: Widget opacity & lock settings +• New: Note sorting (date, title, type) +• New: Parallel downloads (1-10 simultaneous) +• Improved: Grid view as default layout +• Improved: Sync settings reorganized into clear sections +• Improved: Live sync progress with status indicators +• More UI/UX improvements + +Full changelog: +https://github.com/inventory69/simple-notes-sync/blob/main/CHANGELOG.md