diff options
Diffstat (limited to 'android/app/src/main')
11 files changed, 779 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" /> |
