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:
@@ -162,6 +162,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
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)
|
// Testing (bleiben so)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -102,6 +102,25 @@
|
|||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
tools:node="merge" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -5,8 +5,10 @@ import com.thegrizzlylabs.sardineandroid.Sardine
|
|||||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@@ -109,6 +111,73 @@ class SafeSardineWrapper private constructor(
|
|||||||
return delegate.list(url, 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
|
* 🆕 v1.7.2 (IMPL_003): Schließt alle offenen Verbindungen
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -227,6 +227,20 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
broadcastSyncCompleted(true, result.syncedCount)
|
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) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS")
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
|||||||
@@ -1050,56 +1050,61 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
|
|
||||||
val mdUrl = getMarkdownUrl(serverUrl)
|
try {
|
||||||
|
val mdUrl = getMarkdownUrl(serverUrl)
|
||||||
|
|
||||||
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
|
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
|
||||||
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
||||||
|
|
||||||
// Hole ALLE lokalen Notizen (inklusive SYNCED)
|
// Hole ALLE lokalen Notizen (inklusive SYNCED)
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
val totalCount = allNotes.size
|
val totalCount = allNotes.size
|
||||||
var exportedCount = 0
|
var exportedCount = 0
|
||||||
|
|
||||||
// Track used filenames to handle duplicates
|
// Track used filenames to handle duplicates
|
||||||
val usedFilenames = mutableSetOf<String>()
|
val usedFilenames = mutableSetOf<String>()
|
||||||
|
|
||||||
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
||||||
|
|
||||||
allNotes.forEachIndexed { index, note ->
|
allNotes.forEachIndexed { index, note ->
|
||||||
try {
|
try {
|
||||||
// Progress-Callback
|
// Progress-Callback
|
||||||
onProgress(index + 1, totalCount)
|
onProgress(index + 1, totalCount)
|
||||||
|
|
||||||
// Eindeutiger Filename (mit Duplikat-Handling)
|
// Eindeutiger Filename (mit Duplikat-Handling)
|
||||||
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
|
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
|
||||||
val noteUrl = "$mdUrl/$filename"
|
val noteUrl = "$mdUrl/$filename"
|
||||||
|
|
||||||
// Konvertiere zu Markdown
|
// Konvertiere zu Markdown
|
||||||
val mdContent = note.toMarkdown().toByteArray()
|
val mdContent = note.toMarkdown().toByteArray()
|
||||||
|
|
||||||
// Upload (überschreibt falls vorhanden)
|
// Upload (überschreibt falls vorhanden)
|
||||||
sardine.put(noteUrl, mdContent, "text/markdown")
|
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||||
|
|
||||||
exportedCount++
|
exportedCount++
|
||||||
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
|
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
||||||
// Continue mit nächster Note (keine Abbruch bei Einzelfehlern)
|
// 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(
|
private data class DownloadResult(
|
||||||
@@ -1717,58 +1722,63 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val okHttpClient = OkHttpClient.Builder().build()
|
val okHttpClient = OkHttpClient.Builder().build()
|
||||||
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
|
|
||||||
val mdUrl = getMarkdownUrl(serverUrl)
|
try {
|
||||||
|
val mdUrl = getMarkdownUrl(serverUrl)
|
||||||
|
|
||||||
// Check if notes-md/ exists
|
// Check if notes-md/ exists
|
||||||
if (!sardine.exists(mdUrl)) {
|
if (!sardine.exists(mdUrl)) {
|
||||||
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
||||||
return@withContext 0
|
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")
|
val localNotes = storage.loadAllNotes()
|
||||||
importedCount
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Markdown sync failed", e)
|
Logger.e(TAG, "Markdown sync failed", e)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -329,6 +329,17 @@ class NoteEditorViewModel(
|
|||||||
// 🌟 v1.6.0: Trigger onSave Sync
|
// 🌟 v1.6.0: Trigger onSave Sync
|
||||||
triggerOnSaveSync()
|
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)
|
_events.emit(NoteEditorEvent.NavigateBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +388,41 @@ class NoteEditorViewModel(
|
|||||||
|
|
||||||
fun canDelete(): Boolean = existingNote != null
|
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
|
// 🌟 v1.6.0: Sync Trigger - onSave
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
9
android/app/src/main/res/drawable/ic_lock.xml
Normal file
9
android/app/src/main/res/drawable/ic_lock.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_lock_open.xml
Normal file
9
android/app/src/main/res/drawable/ic_lock_open.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_more_vert.xml
Normal file
9
android/app/src/main/res/drawable/ic_more_vert.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_note.xml
Normal file
9
android/app/src/main/res/drawable/ic_note.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_open_in_new.xml
Normal file
9
android/app/src/main/res/drawable/ic_open_in_new.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_refresh.xml
Normal file
9
android/app/src/main/res/drawable/ic_refresh.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_settings.xml
Normal file
9
android/app/src/main/res/drawable/ic_settings.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
58
android/app/src/main/res/layout/widget_preview.xml
Normal file
58
android/app/src/main/res/layout/widget_preview.xml
Normal 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>
|
||||||
@@ -511,4 +511,20 @@
|
|||||||
<item quantity="one">%d erledigt</item>
|
<item quantity="one">%d erledigt</item>
|
||||||
<item quantity="other">%d erledigt</item>
|
<item quantity="other">%d erledigt</item>
|
||||||
</plurals>
|
</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>
|
</resources>
|
||||||
|
|||||||
@@ -530,4 +530,20 @@
|
|||||||
<string name="sync_parallel_downloads_desc_7">Fast (7x faster)</string>
|
<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>
|
<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>
|
</resources>
|
||||||
|
|||||||
20
android/app/src/main/res/xml/note_widget_info.xml
Normal file
20
android/app/src/main/res/xml/note_widget_info.xml
Normal 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" />
|
||||||
Reference in New Issue
Block a user