summaryrefslogtreecommitdiff
path: root/android
diff options
context:
space:
mode:
Diffstat (limited to 'android')
-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
-rw-r--r--android/app/src/main/res/drawable/ic_checkbox_empty.xml14
-rw-r--r--android/app/src/main/res/values/strings.xml4
-rw-r--r--android/app/src/main/res/xml/widget_info.xml13
-rw-r--r--android/app/src/test/java/org/terst/doot/widget/SlotPackerTest.kt80
-rw-r--r--android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt61
13 files changed, 920 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)
+ }
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_checkbox_empty.xml b/android/app/src/main/res/drawable/ic_checkbox_empty.xml
new file mode 100644
index 0000000..087fc9c
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_checkbox_empty.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M8,4 L16,4 Q20,4 20,8 L20,16 Q20,20 16,20 L8,20 Q4,20 4,16 L4,8 Q4,4 8,4 Z"
+ android:strokeWidth="2"
+ android:strokeColor="#FFFFFF"
+ android:fillColor="@android:color/transparent"
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
+</vector>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2e4e6fc
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">Doot Widget</string>
+</resources>
diff --git a/android/app/src/main/res/xml/widget_info.xml b/android/app/src/main/res/xml/widget_info.xml
new file mode 100644
index 0000000..a94bcbe
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_info.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<appwidget-provider
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:minWidth="250dp"
+ android:minHeight="250dp"
+ android:targetCellWidth="4"
+ android:targetCellHeight="4"
+ android:updatePeriodMillis="0"
+ android:configure="org.terst.doot.widget.ui.SettingsActivity"
+ android:widgetCategory="home_screen"
+ android:widgetFeatures="reconfigurable|configuration_optional"
+ android:resizeMode="horizontal|vertical"
+ android:description="@string/app_name" />
diff --git a/android/app/src/test/java/org/terst/doot/widget/SlotPackerTest.kt b/android/app/src/test/java/org/terst/doot/widget/SlotPackerTest.kt
new file mode 100644
index 0000000..b6a078c
--- /dev/null
+++ b/android/app/src/test/java/org/terst/doot/widget/SlotPackerTest.kt
@@ -0,0 +1,80 @@
+package org.terst.doot.widget
+
+import org.junit.Assert.*
+import org.junit.Test
+import org.terst.doot.widget.data.*
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+class SlotPackerTest {
+
+ private fun makeEvent(startIso: String, endIso: String): WidgetItem = WidgetItem(
+ id = "evt-$startIso", title = "Event", source = "calendar", type = "event",
+ start = startIso, end = endIso
+ )
+
+ private fun makeTask(id: String): WidgetItem = WidgetItem(
+ id = id, title = "Task $id", source = "todoist", type = "task",
+ completable = true
+ )
+
+ private val now = Instant.parse("2026-06-28T10:15:00Z")
+
+ @Test
+ fun `empty tasks returns empty fragments`() {
+ val result = SlotPacker.pack(emptyList(), emptyList(), now)
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `single task gets slot starting at now`() {
+ val result = SlotPacker.pack(listOf(makeTask("t1")), emptyList(), now)
+ assertEquals(1, result.size)
+ assertEquals(1, result[0].slots.size)
+ assertEquals(now, result[0].slots[0].slotStart)
+ assertEquals(now.plus(1, ChronoUnit.HOURS), result[0].slots[0].slotEnd)
+ }
+
+ @Test
+ fun `three tasks with no events form one fragment`() {
+ val tasks = listOf(makeTask("t1"), makeTask("t2"), makeTask("t3"))
+ val result = SlotPacker.pack(tasks, emptyList(), now)
+ assertEquals(1, result.size)
+ assertEquals(3, result[0].slots.size)
+ assertEquals(result[0].slots[0].slotEnd, result[0].slots[1].slotStart)
+ assertEquals(result[0].slots[1].slotEnd, result[0].slots[2].slotStart)
+ }
+
+ @Test
+ fun `event between tasks splits into two fragments`() {
+ val tasks = listOf(makeTask("t1"), makeTask("t2"), makeTask("t3"))
+ // Event at 11:15–12:15 falls between slot 1 (10:15–11:15) and slot 2
+ val event = makeEvent("2026-06-28T11:15:00Z", "2026-06-28T12:15:00Z")
+ val result = SlotPacker.pack(tasks, listOf(event), now)
+
+ assertEquals(2, result.size)
+ assertEquals(1, result[0].slots.size) // t1 before event
+ assertEquals(2, result[1].slots.size) // t2, t3 after event
+ }
+
+ @Test
+ fun `task slot jumps past overlapping event`() {
+ val tasks = listOf(makeTask("t1"))
+ // Event occupies 10:00–11:30, overlaps with now (10:15)
+ val event = makeEvent("2026-06-28T10:00:00Z", "2026-06-28T11:30:00Z")
+ val result = SlotPacker.pack(tasks, listOf(event), now)
+
+ assertEquals(1, result.size)
+ val slotStart = result[0].slots[0].slotStart
+ assertEquals(Instant.parse("2026-06-28T11:30:00Z"), slotStart)
+ }
+
+ @Test
+ fun `fragment startTime and endTime match first and last slot`() {
+ val tasks = listOf(makeTask("t1"), makeTask("t2"))
+ val result = SlotPacker.pack(tasks, emptyList(), now)
+ val frag = result[0]
+ assertEquals(frag.slots.first().slotStart, frag.startTime)
+ assertEquals(frag.slots.last().slotEnd, frag.endTime)
+ }
+}
diff --git a/android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt b/android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt
new file mode 100644
index 0000000..d4c2ce3
--- /dev/null
+++ b/android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt
@@ -0,0 +1,61 @@
+package org.terst.doot.widget
+
+import io.mockk.*
+import kotlinx.coroutines.test.runTest
+import okhttp3.*
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.*
+import org.junit.Test
+import org.terst.doot.widget.data.WidgetRepository
+
+class WidgetRepositoryTest {
+
+ private val mockClient = mockk<OkHttpClient>()
+ private val mockCall = mockk<Call>()
+
+ private fun responseOf(code: Int, body: String): Response {
+ return Response.Builder()
+ .request(Request.Builder().url("https://example.com").build())
+ .protocol(Protocol.HTTP_1_1)
+ .code(code)
+ .message("")
+ .body(body.toResponseBody("application/json".toMediaType()))
+ .build()
+ }
+
+ @Test
+ fun `fetch returns failure on non-200`() = runTest {
+ every { mockClient.newCall(any()) } returns mockCall
+ every { mockCall.execute() } returns responseOf(401, "unauthorized")
+
+ val repo = WidgetRepository(mockClient, "https://doot.example.com", "token")
+ val result = repo.fetchRaw()
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `fetch returns success with valid JSON`() = runTest {
+ val json = """{"now":"2026-06-28T10:15:00Z","items":[]}"""
+ every { mockClient.newCall(any()) } returns mockCall
+ every { mockCall.execute() } returns responseOf(200, json)
+
+ val repo = WidgetRepository(mockClient, "https://doot.example.com", "token")
+ val result = repo.fetchRaw()
+ assertTrue(result.isSuccess)
+ assertEquals(0, result.getOrThrow().items.size)
+ }
+
+ @Test
+ fun `fetch sets Authorization header`() = runTest {
+ val json = """{"now":"2026-06-28T10:15:00Z","items":[]}"""
+ val capturedRequest = slot<Request>()
+ every { mockClient.newCall(capture(capturedRequest)) } returns mockCall
+ every { mockCall.execute() } returns responseOf(200, json)
+
+ val repo = WidgetRepository(mockClient, "https://doot.example.com", "mysecret")
+ repo.fetchRaw()
+
+ assertEquals("Bearer mysecret", capturedRequest.captured.header("Authorization"))
+ }
+}