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 @@
+
+
+