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
This commit is contained in:
inventory69
2026-02-10 10:42:40 +01:00
parent 900dad76fe
commit 539987f2ed
27 changed files with 1698 additions and 101 deletions

View File

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

View File

@@ -102,6 +102,25 @@
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- 🆕 v1.8.0: Widget Receiver -->
<receiver
android:name=".widget.NoteWidgetReceiver"
android:exported="true"
android:label="@string/widget_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/note_widget_info" />
</receiver>
<!-- 🆕 v1.8.0: Widget Config Activity -->
<activity
android:name=".widget.NoteWidgetConfigActivity"
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
</application>
</manifest>

View File

@@ -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
@@ -109,6 +111,73 @@ class SafeSardineWrapper private constructor(
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
*

View File

@@ -227,6 +227,20 @@ class SyncWorker(
}
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, "═══════════════════════════════════════")

View File

@@ -1050,6 +1050,7 @@ class WebDavSyncService(private val context: Context) {
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
try {
val mdUrl = getMarkdownUrl(serverUrl)
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
@@ -1100,6 +1101,10 @@ class WebDavSyncService(private val context: Context) {
}
return@withContext exportedCount
} finally {
// 🐛 FIX: Connection Leak — SafeSardineWrapper explizit schließen
sardine.close()
}
}
private data class DownloadResult(
@@ -1717,6 +1722,7 @@ class WebDavSyncService(private val context: Context) {
val okHttpClient = OkHttpClient.Builder().build()
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
try {
val mdUrl = getMarkdownUrl(serverUrl)
// Check if notes-md/ exists
@@ -1769,6 +1775,10 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
importedCount
} finally {
// 🐛 FIX: Connection Leak — SafeSardineWrapper explizit schließen
sardine.close()
}
} catch (e: Exception) {
Logger.e(TAG, "Markdown sync failed", e)

View File

@@ -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()
}
}
/**

View File

@@ -329,6 +329,17 @@ class NoteEditorViewModel(
// 🌟 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)
}
}
@@ -377,6 +388,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<String>(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
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -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<Preferences>()
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
)
}
}
}
}

View File

@@ -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<String>("noteId")
val KEY_ITEM_ID = ActionParameters.Key<String>("itemId")
val KEY_GLANCE_ID = ActionParameters.Key<String>("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)
}
}

View File

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

View File

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

View File

@@ -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<ShowOptionsAction>(
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<String>("extra_note_id") to note.id
)
)
} else {
actionRunCallback<ShowOptionsAction>(
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<ToggleLockAction>(
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<RefreshAction>(
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<OpenConfigAction>(
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<String>("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<ToggleChecklistItemAction>(
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<ToggleChecklistItemAction>(
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
)
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?android:attr/colorBackground" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget Preview Layout for Android 12+ widget picker (previewLayout) -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/widget_preview_background"
android:clipToOutline="true">
<!-- TitleBar Simulation -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingBottom="8dp">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_note_24"
android:importantForAccessibility="no" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/widget_preview_title"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:maxLines="1"
android:ellipsize="end" />
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_more_vert"
android:importantForAccessibility="no"
android:alpha="0.5" />
</LinearLayout>
<!-- Content Preview -->
<TextView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:text="@string/widget_preview_content"
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="5"
android:ellipsize="end"
android:lineSpacingMultiplier="1.3" />
</LinearLayout>

View File

@@ -511,4 +511,20 @@
<item quantity="one">%d erledigt</item>
<item quantity="other">%d erledigt</item>
</plurals>
<!-- ============================= -->
<!-- WIDGETS v1.8.0 -->
<!-- ============================= -->
<string name="widget_name">Simple Notes</string>
<string name="widget_description">Zeige eine Notiz oder Checkliste auf dem Startbildschirm</string>
<string name="widget_config_title">Notiz auswählen</string>
<string name="widget_config_hint">Tippe auf eine Notiz, um sie als Widget hinzuzufügen</string>
<string name="widget_config_save">Speichern</string>
<string name="widget_lock_label">Widget sperren</string>
<string name="widget_lock_description">Versehentliches Bearbeiten verhindern</string>
<string name="widget_note_not_found">Notiz nicht gefunden</string>
<string name="widget_opacity_label">Hintergrund-Transparenz</string>
<string name="widget_opacity_description">Transparenz des Widget-Hintergrunds anpassen</string>
<string name="widget_preview_title">Einkaufsliste</string>
<string name="widget_preview_content">Milch, Eier, Brot, Butter, Käse, Tomaten, Nudeln, Reis, Olivenöl…</string>
</resources>

View File

@@ -530,4 +530,20 @@
<string name="sync_parallel_downloads_desc_7">Fast (7x faster)</string>
<string name="sync_parallel_downloads_desc_10">Maximum (10x faster, may stress server)</string>
<!-- ============================= -->
<!-- WIDGETS v1.8.0 -->
<!-- ============================= -->
<string name="widget_name">Simple Notes</string>
<string name="widget_description">Display a note or checklist on your home screen</string>
<string name="widget_config_title">Choose a Note</string>
<string name="widget_config_hint">Tap a note to add it as a widget</string>
<string name="widget_config_save">Save</string>
<string name="widget_lock_label">Lock widget</string>
<string name="widget_lock_description">Prevent accidental edits</string>
<string name="widget_note_not_found">Note not found</string>
<string name="widget_opacity_label">Background opacity</string>
<string name="widget_opacity_description">Adjust the transparency of the widget background</string>
<string name="widget_preview_title">Shopping List</string>
<string name="widget_preview_content">Milk, eggs, bread, butter, cheese, tomatoes, pasta, rice, olive oil…</string>
</resources>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 🆕 v1.8.0: Widget metadata for homescreen note/checklist widget -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="80dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="530dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:updatePeriodMillis="1800000"
android:configure="dev.dettmer.simplenotes.widget.NoteWidgetConfigActivity"
android:previewImage="@layout/widget_preview"
android:previewLayout="@layout/widget_preview"
android:widgetFeatures="reconfigurable" />