summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/org/terst')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/DootWidgetReceiver.kt33
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/SlotPacker.kt89
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt61
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt24
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt272
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt169
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt46
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt54
8 files changed, 748 insertions, 0 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/DootWidgetReceiver.kt b/android/app/src/main/java/org/terst/doot/widget/DootWidgetReceiver.kt
new file mode 100644
index 0000000..94ff4b6
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/DootWidgetReceiver.kt
@@ -0,0 +1,33 @@
+package org.terst.doot.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.work.WorkManager
+import org.terst.doot.widget.ui.DootWidget
+import org.terst.doot.widget.work.RefreshWorker
+
+class DootWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = DootWidget()
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ RefreshWorker.runOnce(context)
+ }
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ RefreshWorker.schedule(context)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ WorkManager.getInstance(context).cancelUniqueWork(RefreshWorker.WORK_NAME)
+ }
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/SlotPacker.kt b/android/app/src/main/java/org/terst/doot/widget/data/SlotPacker.kt
new file mode 100644
index 0000000..da388de
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/data/SlotPacker.kt
@@ -0,0 +1,89 @@
+package org.terst.doot.widget.data
+
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+data class FloatingSlot(
+ val task: WidgetItem,
+ val slotStart: Instant,
+ val slotEnd: Instant
+)
+
+data class TaskFragment(
+ val slots: List<FloatingSlot>,
+ val startTime: Instant,
+ val endTime: Instant
+)
+
+object SlotPacker {
+ /**
+ * Assigns 1-hour slots to floating tasks, packing them forward from [now],
+ * bumping past any overlapping scheduled events.
+ * Returns groups of consecutive task slots as TaskFragments.
+ * A new fragment starts whenever a scheduled event falls between two task slots.
+ */
+ fun pack(
+ floatingTasks: List<WidgetItem>,
+ scheduledEvents: List<WidgetItem>,
+ now: Instant
+ ): List<TaskFragment> {
+ if (floatingTasks.isEmpty()) return emptyList()
+
+ val eventIntervals: List<Pair<Instant, Instant>> = scheduledEvents.mapNotNull { event ->
+ val s = event.start?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null
+ val e = event.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null
+ s to e
+ }
+
+ val slots = mutableListOf<FloatingSlot>()
+ var cursor = now
+
+ for (task in floatingTasks) {
+ var slotStart = cursor
+ var slotEnd = slotStart.plus(1, ChronoUnit.HOURS)
+
+ // Bump cursor past any overlapping event, re-checking after each bump
+ var bumped = true
+ while (bumped) {
+ bumped = false
+ for ((evStart, evEnd) in eventIntervals) {
+ if (slotStart < evEnd && slotEnd > evStart) {
+ slotStart = evEnd
+ slotEnd = slotStart.plus(1, ChronoUnit.HOURS)
+ bumped = true
+ break
+ }
+ }
+ }
+
+ slots.add(FloatingSlot(task, slotStart, slotEnd))
+ cursor = slotEnd
+ }
+
+ // Group consecutive slots into fragments, splitting when a scheduled event falls between them
+ val fragments = mutableListOf<TaskFragment>()
+ var current = mutableListOf(slots[0])
+
+ for (i in 1 until slots.size) {
+ val prevEnd = slots[i - 1].slotEnd
+ val nextStart = slots[i].slotStart
+ val eventBetween = prevEnd < nextStart && eventIntervals.any { (evStart, _) ->
+ evStart >= prevEnd && evStart < nextStart
+ }
+ if (eventBetween) {
+ fragments.add(current.toFragment())
+ current = mutableListOf()
+ }
+ current.add(slots[i])
+ }
+ fragments.add(current.toFragment())
+
+ return fragments
+ }
+
+ private fun List<FloatingSlot>.toFragment() = TaskFragment(
+ slots = this,
+ startTime = first().slotStart,
+ endTime = last().slotEnd
+ )
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
new file mode 100644
index 0000000..6f3d2b2
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
@@ -0,0 +1,61 @@
+package org.terst.doot.widget.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+private val json = Json { ignoreUnknownKeys = true }
+
+class WidgetRepository(
+ private val client: OkHttpClient,
+ private val serverUrl: String,
+ private val token: String
+) {
+ /** Fetches /api/widget and returns the parsed response. Does NOT persist. */
+ suspend fun fetchRaw(): Result<WidgetResponse> = withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget")
+ .header("Authorization", "Bearer $token")
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val body = checkNotNull(response.body?.string()) { "Empty body" }
+ json.decodeFromString<WidgetResponse>(body)
+ }
+ }
+
+ /** Fetches and persists to DataStore. Call this from workers. */
+ suspend fun fetchAndPersist(context: Context): Result<WidgetResponse> {
+ return fetchRaw().onSuccess { resp ->
+ context.dataStore.edit { prefs ->
+ prefs[Keys.ITEMS_JSON] = json.encodeToString(resp.items)
+ prefs[Keys.NOW] = resp.now
+ prefs[Keys.LAST_UPDATED] = System.currentTimeMillis()
+ }
+ }
+ }
+
+ /** POSTs a task completion to /api/widget/complete. */
+ suspend fun complete(context: Context, id: String, source: String): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val body = """{"id":"$id","source":"$source"}"""
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/complete")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
new file mode 100644
index 0000000..27a49e6
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
@@ -0,0 +1,24 @@
+package org.terst.doot.widget.ui
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.action.ActionParameters
+import androidx.glance.appwidget.action.ActionCallback
+import org.terst.doot.widget.work.CompleteWorker
+
+class CompleteTaskAction : ActionCallback {
+ override suspend fun onAction(
+ context: Context,
+ glanceId: GlanceId,
+ parameters: ActionParameters
+ ) {
+ val id = parameters[idKey] ?: return
+ val source = parameters[sourceKey] ?: return
+ CompleteWorker.enqueue(context, id, source)
+ }
+
+ companion object {
+ val idKey = ActionParameters.Key<String>("item_id")
+ val sourceKey = ActionParameters.Key<String>("item_source")
+ }
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
new file mode 100644
index 0000000..c7e48be
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
@@ -0,0 +1,272 @@
+package org.terst.doot.widget.ui
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.*
+import androidx.glance.action.actionParametersOf
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import androidx.glance.layout.*
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import kotlinx.coroutines.flow.first
+import kotlinx.serialization.json.Json
+import org.terst.doot.widget.data.*
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+fun sourceColor(source: String): Color = when (source) {
+ "todoist" -> Color(0xFFE44332)
+ "trello" -> Color(0xFF0079BF)
+ "calendar" -> Color(0xFF9B59B6)
+ "plantoeat" -> Color(0xFF5CB85C)
+ "gtasks" -> Color(0xFFF39C12)
+ else -> Color(0xFF888888)
+}
+
+private val json = Json { ignoreUnknownKeys = true }
+
+class DootWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val prefs = context.dataStore.data.first()
+ val items = parseItems(prefs)
+ val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() }
+ ?: Instant.now()
+
+ provideContent {
+ WidgetRoot(items, now)
+ }
+ }
+
+ private fun parseItems(prefs: Preferences): List<WidgetItem> {
+ val raw = prefs[Keys.ITEMS_JSON] ?: return emptyList()
+ return runCatching { json.decodeFromString<List<WidgetItem>>(raw) }.getOrDefault(emptyList())
+ }
+}
+
+@Composable
+fun WidgetRoot(items: List<WidgetItem>, now: Instant) {
+ val zone = ZoneId.systemDefault()
+ val nowZoned: ZonedDateTime = now.atZone(zone)
+ val scheduled = items.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) }
+ val floating = items.filter { it.start == null }
+ val fragments = SlotPacker.pack(floating, scheduled, now)
+ val gridStart = calcGridStart(scheduled, nowZoned.hour)
+ val gridEnd = calcGridEnd(scheduled, nowZoned.hour)
+
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(Color.Transparent)
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) {
+ Text(
+ "TODAY",
+ style = TextStyle(
+ color = ColorProvider(Color(0x66FFFFFF)),
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold
+ )
+ )
+ }
+
+ for (hour in gridStart..gridEnd) {
+ HourRow(hour, nowZoned, scheduled, fragments, zone)
+ }
+ }
+}
+
+@Composable
+fun HourRow(
+ hour: Int,
+ nowZoned: ZonedDateTime,
+ scheduled: List<WidgetItem>,
+ fragments: List<TaskFragment>,
+ zone: ZoneId
+) {
+ val hourStart = nowZoned.withHour(hour).withMinute(0).withSecond(0).withNano(0).toInstant()
+ val hourEnd = nowZoned.withHour(hour + 1).withMinute(0).withSecond(0).withNano(0).toInstant()
+ val isNowHour = nowZoned.hour == hour
+
+ val eventsHere = scheduled.filter { event ->
+ event.start?.let { s ->
+ val start = Instant.parse(s)
+ start >= hourStart && start < hourEnd
+ } ?: false
+ }
+ val fragsHere = fragments.filter { frag ->
+ frag.startTime >= hourStart && frag.startTime < hourEnd
+ }
+
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().wrapContentHeight(),
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ text = hourLabel(hour),
+ style = TextStyle(color = ColorProvider(Color(0x4DFFFFFF)), fontSize = 8.sp),
+ modifier = GlanceModifier.width(28.dp).padding(top = 2.dp)
+ )
+
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Color(0x1AFFFFFF))
+ )
+
+ if (isNowHour) {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Color(0xBFFFFFFF.toInt()))
+ .padding(vertical = 2.dp)
+ )
+ }
+
+ eventsHere.forEach { event ->
+ val isPast = event.end?.let { Instant.parse(it) < nowZoned.toInstant() } ?: false
+ EventBlock(event, isPast)
+ }
+
+ fragsHere.forEach { frag ->
+ TaskFragmentBlock(frag)
+ }
+ }
+ }
+}
+
+@Composable
+fun EventBlock(event: WidgetItem, isPast: Boolean) {
+ val color = sourceColor(event.source)
+ val alpha = if (isPast) 0.5f else 1f
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url)))),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = GlanceModifier
+ .width(3.dp)
+ .height(20.dp)
+ .background(color.copy(alpha = alpha))
+ )
+ Text(
+ text = event.title,
+ style = TextStyle(
+ color = ColorProvider(Color.White.copy(alpha = alpha)),
+ fontSize = 11.sp
+ ),
+ modifier = GlanceModifier.padding(start = 6.dp),
+ maxLines = 1
+ )
+ }
+}
+
+@Composable
+fun TaskFragmentBlock(fragment: TaskFragment) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ .background(Color(0xFF1E1E1E))
+ ) {
+ Text(
+ "TODAY",
+ style = TextStyle(
+ color = ColorProvider(Color(0x73FFFFFF)),
+ fontSize = 7.sp,
+ fontWeight = FontWeight.Bold
+ ),
+ modifier = GlanceModifier.padding(start = 8.dp, top = 3.dp, bottom = 1.dp)
+ )
+
+ fragment.slots.forEach { slot ->
+ TaskRow(slot.task)
+ }
+ }
+}
+
+@Composable
+fun TaskRow(task: WidgetItem) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (task.completable) {
+ Image(
+ provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_checkbox_empty),
+ contentDescription = "Complete ${task.title}",
+ colorFilter = ColorFilter.tint(ColorProvider(sourceColor(task.source))),
+ modifier = GlanceModifier.size(14.dp).clickable(
+ actionRunCallback<CompleteTaskAction>(
+ actionParametersOf(
+ CompleteTaskAction.idKey to task.id,
+ CompleteTaskAction.sourceKey to task.source
+ )
+ )
+ )
+ )
+ } else {
+ Box(
+ modifier = GlanceModifier
+ .size(8.dp)
+ .background(sourceColor(task.source).copy(alpha = 0.6f))
+ )
+ }
+
+ Text(
+ text = task.title,
+ style = TextStyle(
+ color = ColorProvider(Color(0xFFDDDDDD)),
+ fontSize = 10.sp
+ ),
+ modifier = GlanceModifier.padding(start = 7.dp),
+ maxLines = 1
+ )
+ }
+}
+
+private fun hourLabel(hour: Int): String = when {
+ hour == 0 -> "12a"
+ hour < 12 -> "${hour}a"
+ hour == 12 -> "12p"
+ else -> "${hour - 12}p"
+}
+
+private fun calcGridStart(events: List<WidgetItem>, nowHour: Int): Int {
+ val earliest = events.mapNotNull { it.start }
+ .mapNotNull { runCatching { Instant.parse(it) }.getOrNull() }
+ .minOrNull()
+ ?.atZone(ZoneId.systemDefault())?.hour
+ ?: nowHour
+ return maxOf(0, minOf(earliest, nowHour) - 1)
+}
+
+private fun calcGridEnd(events: List<WidgetItem>, nowHour: Int): Int {
+ val latest = events.mapNotNull { it.end }
+ .mapNotNull { runCatching { Instant.parse(it) }.getOrNull() }
+ .maxOrNull()
+ ?.atZone(ZoneId.systemDefault())?.hour
+ ?: (nowHour + 8)
+ return minOf(23, maxOf(latest, nowHour + 1) + 1)
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt
new file mode 100644
index 0000000..485c067
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt
@@ -0,0 +1,169 @@
+package org.terst.doot.widget.ui
+
+import android.appwidget.AppWidgetManager
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.edit
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import okhttp3.OkHttpClient
+import org.terst.doot.widget.data.Keys
+import org.terst.doot.widget.data.WidgetRepository
+import org.terst.doot.widget.data.dataStore
+import org.terst.doot.widget.work.RefreshWorker
+
+class SettingsActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val appWidgetId = intent?.extras?.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ setResult(RESULT_CANCELED)
+
+ setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ val scope = rememberCoroutineScope()
+ var savedUrl by remember { mutableStateOf("") }
+ var savedToken by remember { mutableStateOf("") }
+
+ LaunchedEffect(Unit) {
+ val prefs = dataStore.data.first()
+ savedUrl = prefs[Keys.SERVER_URL] ?: ""
+ savedToken = prefs[Keys.TOKEN] ?: ""
+ }
+
+ SettingsScreen(
+ initialUrl = savedUrl,
+ initialToken = savedToken,
+ onSave = { url, token ->
+ lifecycleScope.launch {
+ saveAndFinish(url, token, appWidgetId)
+ }
+ },
+ onTest = { url, token, onResult ->
+ scope.launch {
+ val repo = WidgetRepository(OkHttpClient(), url.trimEnd('/'), token)
+ repo.fetchRaw().fold(
+ onSuccess = { onResult("✓ Connected (${it.items.size} items)") },
+ onFailure = { onResult("✗ ${it.message}") }
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+
+ private suspend fun saveAndFinish(url: String, token: String, appWidgetId: Int) {
+ dataStore.edit { prefs ->
+ prefs[Keys.SERVER_URL] = url.trimEnd('/')
+ prefs[Keys.TOKEN] = token
+ }
+ RefreshWorker.schedule(this)
+ RefreshWorker.runOnce(this)
+
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
+ setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
+ } else {
+ setResult(RESULT_OK)
+ }
+ finish()
+ }
+}
+
+@Composable
+fun SettingsScreen(
+ initialUrl: String = "",
+ initialToken: String = "",
+ onSave: (url: String, token: String) -> Unit,
+ onTest: (url: String, token: String, onResult: (String) -> Unit) -> Unit
+) {
+ var url by remember(initialUrl) { mutableStateOf(initialUrl) }
+ var token by remember(initialToken) { mutableStateOf(initialToken) }
+ var status by remember { mutableStateOf("") }
+ var testing by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
+ ) {
+ Text("Doot Widget", style = MaterialTheme.typography.headlineMedium)
+ Text(
+ "Enter your doot server URL and the WIDGET_TOKEN from your .env file.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Server URL") },
+ placeholder = { Text("https://doot.example.com") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ singleLine = true
+ )
+
+ OutlinedTextField(
+ value = token,
+ onValueChange = { token = it },
+ label = { Text("Widget Token") },
+ modifier = Modifier.fillMaxWidth(),
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ singleLine = true
+ )
+
+ if (status.isNotEmpty()) {
+ Text(
+ status,
+ color = if (status.startsWith("✓"))
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedButton(
+ onClick = {
+ testing = true
+ status = "Testing..."
+ onTest(url, token) { result ->
+ status = result
+ testing = false
+ }
+ },
+ enabled = url.isNotBlank() && token.isNotBlank() && !testing
+ ) {
+ Text("Test")
+ }
+
+ Button(
+ onClick = { onSave(url, token) },
+ enabled = url.isNotBlank() && token.isNotBlank()
+ ) {
+ Text("Save")
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt
new file mode 100644
index 0000000..d3017ff
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt
@@ -0,0 +1,46 @@
+package org.terst.doot.widget.work
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import androidx.work.*
+import kotlinx.coroutines.flow.first
+import org.terst.doot.widget.ui.DootWidget
+import org.terst.doot.widget.data.Keys
+import org.terst.doot.widget.data.WidgetRepository
+import org.terst.doot.widget.data.dataStore
+
+class CompleteWorker(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val id = inputData.getString(KEY_ID) ?: return Result.failure()
+ val source = inputData.getString(KEY_SOURCE) ?: return Result.failure()
+
+ val prefs = applicationContext.dataStore.data.first()
+ val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure()
+ val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure()
+
+ val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token)
+ return repo.complete(applicationContext, id, source).fold(
+ onSuccess = {
+ repo.fetchAndPersist(applicationContext)
+ DootWidget().updateAll(applicationContext)
+ Result.success()
+ },
+ onFailure = { Result.retry() }
+ )
+ }
+
+ companion object {
+ const val KEY_ID = "item_id"
+ const val KEY_SOURCE = "item_source"
+
+ fun enqueue(context: Context, id: String, source: String) {
+ val data = workDataOf(KEY_ID to id, KEY_SOURCE to source)
+ val request = OneTimeWorkRequestBuilder<CompleteWorker>()
+ .setInputData(data)
+ .build()
+ WorkManager.getInstance(context).enqueue(request)
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt
new file mode 100644
index 0000000..5c0e89c
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt
@@ -0,0 +1,54 @@
+package org.terst.doot.widget.work
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import androidx.work.*
+import kotlinx.coroutines.flow.first
+import org.terst.doot.widget.ui.DootWidget
+import org.terst.doot.widget.data.Keys
+import org.terst.doot.widget.data.WidgetRepository
+import org.terst.doot.widget.data.dataStore
+import java.util.concurrent.TimeUnit
+
+class RefreshWorker(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val prefs = applicationContext.dataStore.data.first()
+ val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure()
+ val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure()
+
+ val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token)
+ return repo.fetchAndPersist(applicationContext).fold(
+ onSuccess = {
+ DootWidget().updateAll(applicationContext)
+ Result.success()
+ },
+ onFailure = { Result.retry() }
+ )
+ }
+
+ companion object {
+ const val WORK_NAME = "doot_widget_refresh"
+
+ fun schedule(context: Context) {
+ val request = PeriodicWorkRequestBuilder<RefreshWorker>(15, TimeUnit.MINUTES)
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ request
+ )
+ }
+
+ fun runOnce(context: Context) {
+ val request = OneTimeWorkRequestBuilder<RefreshWorker>().build()
+ WorkManager.getInstance(context).enqueue(request)
+ }
+ }
+}