From 539987f2ed41f8abcde6c6028d7aa29a7216b583 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Tue, 10 Feb 2026 10:42:40 +0100 Subject: [PATCH] feat(v1.8.0): IMPL_019 Homescreen Widgets Implementation Complete Jetpack Glance Widget Framework - Implement NoteWidget with 5 responsive size classes - Support TEXT and CHECKLIST note types - Material You dynamic colors integration - Interactive checklist checkboxes in large layouts - Read-only mode for locked widgets Widget Configuration System - NoteWidgetConfigActivity for placement and reconfiguration (Android 12+) - NoteWidgetConfigScreen with note selection and settings - Lock widget toggle to prevent accidental edits - Background opacity slider with 0-100% range - Auto-save on back navigation plus Save FAB Widget State Management - NoteWidgetState keys for per-instance persistence via DataStore - NoteWidgetActionKeys for type-safe parameter passing - Five top-level ActionCallback classes * ToggleChecklistItemAction (updates checklist and marks for sync) * ToggleLockAction (toggle read-only mode) * ShowOptionsAction (show permanent options bar) * RefreshAction (reload from storage) * OpenConfigAction (launch widget config activity) Responsive Layout System - SMALL (110x80dp): Title only - NARROW_MEDIUM (110x110dp): Preview or compact checklist - NARROW_TALL (110x250dp): Full content display - WIDE_MEDIUM (250x110dp): Preview layout - WIDE_TALL (250x250dp): Interactive checklist Interactive Widget Features - Tap content to open editor (unlock) or show options (lock) - Checklist checkboxes with immediate state sync - Options bar with Lock/Unlock, Refresh, Settings, Open in App buttons - Per-widget background transparency control Connection Leak Fixes (Part of IMPL_019) - Override put/delete/createDirectory in SafeSardineWrapper with response.use{} - Proper resource cleanup in exportAllNotesToMarkdown and syncMarkdownFiles - Use modern OkHttp APIs (toMediaTypeOrNull, toRequestBody) UI Improvements (Part of IMPL_019) - Checkbox toggle includes KEY_LAST_UPDATED to force Glance recomposition - Note selection in config is visual-only (separate from save) - Config uses moveTaskToBack() plus FLAG_ACTIVITY_CLEAR_TASK - Proper options bar with standard Material icons Resources and Configuration - 8 drawable icons for widget controls - Widget metadata file (note_widget_info.xml) - Widget preview layout for Android 12+ widget picker - Multi-language strings (English and German) - Glance Jetpack dependencies version 1.1.1 System Integration - SyncWorker updates all widgets after sync completion - NoteEditorViewModel reloads checklist state on resume - ComposeNoteEditorActivity reflects widget edits - WebDavSyncService maintains clean connections - AndroidManifest declares widget receiver and config activity Complete v1.8.0 Widget Feature Set - Fully responsive design for phones, tablets and foldables - Seamless Material Design 3 integration - Production-ready error handling - Zero connection leaks - Immediate UI feedback for all interactions --- android/app/build.gradle.kts | 6 + android/app/src/main/AndroidManifest.xml | 19 + .../simplenotes/sync/SafeSardineWrapper.kt | 69 +++ .../dettmer/simplenotes/sync/SyncWorker.kt | 16 +- .../simplenotes/sync/WebDavSyncService.kt | 206 +++---- .../ui/editor/ComposeNoteEditorActivity.kt | 12 + .../ui/editor/NoteEditorViewModel.kt | 50 +- .../dettmer/simplenotes/widget/NoteWidget.kt | 77 +++ .../simplenotes/widget/NoteWidgetActions.kt | 163 ++++++ .../widget/NoteWidgetConfigActivity.kt | 160 ++++++ .../widget/NoteWidgetConfigScreen.kt | 269 +++++++++ .../simplenotes/widget/NoteWidgetContent.kt | 522 ++++++++++++++++++ .../simplenotes/widget/NoteWidgetReceiver.kt | 13 + .../simplenotes/widget/NoteWidgetState.kt | 29 + android/app/src/main/res/drawable/ic_lock.xml | 9 + .../src/main/res/drawable/ic_lock_open.xml | 9 + .../src/main/res/drawable/ic_more_vert.xml | 9 + android/app/src/main/res/drawable/ic_note.xml | 9 + .../src/main/res/drawable/ic_open_in_new.xml | 9 + .../app/src/main/res/drawable/ic_refresh.xml | 9 + .../app/src/main/res/drawable/ic_settings.xml | 9 + .../main/res/drawable/ic_widget_checklist.xml | 9 + .../drawable/widget_preview_background.xml | 6 + .../src/main/res/layout/widget_preview.xml | 58 ++ .../app/src/main/res/values-de/strings.xml | 16 + android/app/src/main/res/values/strings.xml | 16 + .../app/src/main/res/xml/note_widget_info.xml | 20 + 27 files changed, 1698 insertions(+), 101 deletions(-) create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidget.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetActions.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetContent.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetReceiver.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetState.kt create mode 100644 android/app/src/main/res/drawable/ic_lock.xml create mode 100644 android/app/src/main/res/drawable/ic_lock_open.xml create mode 100644 android/app/src/main/res/drawable/ic_more_vert.xml create mode 100644 android/app/src/main/res/drawable/ic_note.xml create mode 100644 android/app/src/main/res/drawable/ic_open_in_new.xml create mode 100644 android/app/src/main/res/drawable/ic_refresh.xml create mode 100644 android/app/src/main/res/drawable/ic_settings.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_checklist.xml create mode 100644 android/app/src/main/res/drawable/widget_preview_background.xml create mode 100644 android/app/src/main/res/layout/widget_preview.xml create mode 100644 android/app/src/main/res/xml/note_widget_info.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6ce807d..c118a97 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c348297..27d5d9d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -102,6 +102,25 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> + + + + + + + + + + + \ No newline at end of file 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..8826943 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,8 +5,10 @@ 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 @@ -108,6 +110,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 != 405) { // 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/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 8891c74..a4136cb 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 @@ -1050,56 +1050,61 @@ 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( @@ -1717,59 +1722,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/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/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt index b15458c..adeacf3 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 @@ -325,10 +325,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) } } @@ -376,6 +387,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/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..7c357f7 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigActivity.kt @@ -0,0 +1,160 @@ +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.datastore.preferences.core.Preferences +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..8aee4a8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetConfigScreen.kt @@ -0,0 +1,269 @@ +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 + */ +@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, + onCancel: () -> Unit +) { + 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(50).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..a7bdcf5 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/widget/NoteWidgetContent.kt @@ -0,0 +1,522 @@ +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.FontWeight +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 ── + +enum class WidgetSizeClass { + SMALL, // Nur Titel + NARROW_MED, // Schmal, Vorschau + NARROW_TALL, // Schmal, voller Inhalt + WIDE_MED, // Breit, Vorschau + WIDE_TALL // Breit, voller Inhalt +} + +private fun DpSize.toSizeClass(): WidgetSizeClass = when { + height < 110.dp -> WidgetSizeClass.SMALL + width < 250.dp && height < 250.dp -> WidgetSizeClass.NARROW_MED + width < 250.dp -> WidgetSizeClass.NARROW_TALL + height < 250.dp -> 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) 100 else 200), + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = if (compact) 13.sp else 14.sp + ), + maxLines = if (compact) 2 else 3, + modifier = GlanceModifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) +} + +@Composable +private fun TextNoteFullView(note: Note) { + LazyColumn( + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 12.dp) + ) { + val paragraphs = note.content.split("\n").filter { it.isNotBlank() } + items(paragraphs.size) { index -> + Text( + text = paragraphs[index], + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 14.sp + ), + modifier = GlanceModifier.padding(bottom = 4.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/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/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 af204a4..b8902ed 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -511,4 +511,20 @@ %d erledigt %d erledigt + + + + + 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 8927ebe..f3e565a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -530,4 +530,20 @@ 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/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 @@ + + +