Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 161 additions & 53 deletions app/src/main/kotlin/com/apollof/protocoltracker/widget/TodayWidget.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.apollof.protocoltracker.widget

import android.content.Context
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
Expand All @@ -10,14 +12,17 @@ import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
Expand All @@ -41,103 +46,206 @@ import androidx.glance.semantics.semantics
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import com.apollof.protocoltracker.MainActivity
import com.apollof.protocoltracker.R
import com.apollof.protocoltracker.container
import com.apollof.protocoltracker.data.Palette
import com.apollof.protocoltracker.domain.schedule.AgendaStatus
import com.apollof.protocoltracker.domain.schedule.AgendaWindows
import com.apollof.protocoltracker.domain.schedule.WidgetLine
import com.apollof.protocoltracker.domain.schedule.agendaLogsFrom
import com.apollof.protocoltracker.domain.schedule.buildAgenda
import com.apollof.protocoltracker.domain.schedule.nextDose
import com.apollof.protocoltracker.domain.schedule.widgetLines
import com.apollof.protocoltracker.domain.units.describeDose
import com.apollof.protocoltracker.ui.theme.materialScheme
import com.apollof.protocoltracker.ui.theme.trackerColors

private data class WidgetRow(val key: String, val name: String, val detail: String, val overdue: Boolean)
private enum class Mark { DUE, MISSED, TAKEN, SKIPPED }

private data class WidgetRow(val key: String, val name: String, val detail: String, val mark: Mark) {
val checkable: Boolean get() = mark == Mark.DUE || mark == Mark.MISSED
}

private data class WidgetState(val rows: List<WidgetRow>, val next: WidgetRow?, val done: Int, val total: Int)

class TodayWidget : GlanceAppWidget() {
// One row (2x1 and wider single rows), the next dose under a header (2x2), the day's list (3x2 and up).
override val sizeMode = SizeMode.Responsive(setOf(ROW, CARD, LIST))

override suspend fun provideGlance(context: Context, id: GlanceId) {
val c = context.container
val now = c.clock()
val protocol = c.repository.protocolNow()
val zone = c.zone()
val slotTimes = c.settings.current().slotTimes
val logs = c.repository.logsSinceNow(agendaLogsFrom(now, zone, slotTimes))
val agenda = buildAgenda(protocol.phases, protocol.items, logs, now, zone, c.repository.anchorsNow(), slotTimes)
fun row(e: com.apollof.protocoltracker.domain.schedule.AgendaEntry, label: String): WidgetRow? {
val occ = e.occurrence ?: return null
val prefs = c.settings.current()
val logs = c.repository.logsSinceNow(agendaLogsFrom(now, zone, prefs.slotTimes))
val agenda = buildAgenda(protocol.phases, protocol.items, logs, now, zone, c.repository.anchorsNow(), prefs.slotTimes)
val lines = agenda.widgetLines { it.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.getDefault()) }
fun row(line: WidgetLine): WidgetRow? {
val occ = line.entry.occurrence ?: return null
val compound = protocol.compounds[occ.item.compoundId] ?: return null
return WidgetRow(
key = occ.key, name = compound.displayName,
detail = "$label · ${describeDose(occ.dose, compound.baseUnit, occ.item.formulation)}",
overdue = e.status == AgendaStatus.MISSED,
)
val mark = when (line.entry.status) {
AgendaStatus.MISSED -> Mark.MISSED
AgendaStatus.PENDING -> Mark.DUE
AgendaStatus.TAKEN -> Mark.TAKEN
AgendaStatus.SKIPPED -> Mark.SKIPPED
}
val status = when (mark) {
Mark.MISSED -> "Missed · "
Mark.TAKEN -> "Taken · "
Mark.SKIPPED -> "Skipped · "
Mark.DUE -> ""
}
val dose = describeDose(occ.dose, compound.baseUnit, occ.item.formulation)
return WidgetRow(occ.key, compound.displayName, "$status${line.label} · $dose", mark)
}
val rows = agenda.missed.mapNotNull { row(it, it.occurrence!!.localDate.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.getDefault())) } +
agenda.groups.flatMap { g -> g.pending.mapNotNull { row(it, g.label) } }
val done = agenda.doneToday
// The app's scheme, following the system light/dark mode; the wallpaper scheme uses Glance's dynamic colours.
val prefs = c.settings.current()
val colors = if (prefs.palette == Palette.DYNAMIC) null else ColorProviders(
val state = WidgetState(lines.mapNotNull(::row), lines.nextDose()?.let(::row), agenda.doneToday, agenda.scheduledToday)
// Wallpaper colours on Android 12+, like the launcher's own widgets; before that the app's colour scheme.
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) null else ColorProviders(
light = materialScheme(trackerColors(prefs.palette, dark = false)),
dark = materialScheme(trackerColors(prefs.palette, dark = true, pureBlack = prefs.pureBlack)),
)
provideContent {
if (colors == null) GlanceTheme { Content(rows, done) } else GlanceTheme(colors = colors) { Content(rows, done) }
if (colors == null) GlanceTheme { Content(state) } else GlanceTheme(colors = colors) { Content(state) }
}
}

@Composable
private fun Content(rows: List<WidgetRow>, done: Int) {
Column(
modifier = GlanceModifier.fillMaxSize().background(GlanceTheme.colors.background).cornerRadius(20.dp).padding(12.dp),
) {
Row(
modifier = GlanceModifier.fillMaxWidth().clickable(actionStartActivity<MainActivity>()),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Today", style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp, color = GlanceTheme.colors.onSurface))
Spacer(GlanceModifier.defaultWeight())
Text("$done done", style = TextStyle(fontSize = 12.sp, color = GlanceTheme.colors.onSurfaceVariant))
private fun Content(state: WidgetState) {
val size = LocalSize.current
Box(modifier = GlanceModifier.fillMaxSize().widgetCard()) {
when {
size.width >= LIST.width && size.height >= LIST.height -> ListLayout(state)
size.height >= CARD.height -> CardLayout(state)
else -> RowLayout(state)
}
Spacer(GlanceModifier.height(8.dp))
if (rows.isEmpty()) {
Text("Nothing left today", style = TextStyle(fontSize = 14.sp, color = GlanceTheme.colors.onSurfaceVariant))
}
}

@Composable
private fun ListLayout(state: WidgetState) {
Column(modifier = GlanceModifier.fillMaxSize().padding(start = 8.dp, top = 12.dp, end = 12.dp)) {
Header(state)
Spacer(GlanceModifier.height(4.dp))
if (state.rows.isEmpty()) {
Text(
"Nothing left today", modifier = GlanceModifier.padding(horizontal = 8.dp),
style = TextStyle(fontSize = 14.sp, color = GlanceTheme.colors.onSurfaceVariant),
)
} else {
LazyColumn {
items(rows, itemId = { it.key.hashCode().toLong() }) { row -> RowItem(row) }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(state.rows, itemId = { it.key.hashCode().toLong() }) { row -> DoseRow(row) }
}
}
}
}

@Composable
private fun RowItem(row: WidgetRow) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
// An empty ring, like the in-app check: pending doses never look taken.
Box(
modifier = GlanceModifier.size(48.dp)
.clickable(actionRunCallback<CheckAction>(actionParametersOf(CheckAction.KEY to row.key)))
.semantics { contentDescription = "Mark ${row.name} taken" },
contentAlignment = Alignment.Center,
) {
Image(
ImageProvider(R.drawable.widget_check_ring), contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.outline), modifier = GlanceModifier.size(36.dp),
)
}
Spacer(GlanceModifier.width(10.dp))
private fun CardLayout(state: WidgetState) {
Column(modifier = GlanceModifier.fillMaxSize().padding(start = 8.dp, top = 12.dp, end = 12.dp, bottom = 8.dp)) {
Header(state)
Spacer(GlanceModifier.defaultWeight())
NextOrDone(state)
}
}

@Composable
private fun RowLayout(state: WidgetState) {
Row(
modifier = GlanceModifier.fillMaxSize().padding(start = 4.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = GlanceModifier.defaultWeight()) { NextOrDone(state) }
Text(
"${state.done}/${state.total}", modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()),
style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 14.sp, color = GlanceTheme.colors.primary),
)
}
}

@Composable
private fun NextOrDone(state: WidgetState) {
val next = state.next
if (next != null) {
DoseRow(next)
} else {
val detail = if (state.total == 0) "No doses planned" else "${state.done} of ${state.total} done"
DoseRow(WidgetRow("", "Nothing left today", detail, Mark.TAKEN))
}
}

@Composable
private fun Header(state: WidgetState) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 8.dp).clickable(actionStartActivity<MainActivity>()),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Today", style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp, color = GlanceTheme.colors.onSurface))
Spacer(GlanceModifier.defaultWeight())
Text(
"${state.done} of ${state.total} done", maxLines = 1,
style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 13.sp, color = GlanceTheme.colors.primary),
)
}
}

@Composable
private fun DoseRow(row: WidgetRow) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
StatusMark(row)
Spacer(GlanceModifier.width(4.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity<MainActivity>())) {
Text(row.name, maxLines = 1, style = TextStyle(fontWeight = FontWeight.Medium, fontSize = 14.sp, color = GlanceTheme.colors.onSurface))
Text(
(if (row.overdue) "Missed · " else "") + row.detail, maxLines = 1,
style = TextStyle(fontSize = 12.sp, color = if (row.overdue) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant),
row.detail, maxLines = 1,
style = TextStyle(fontSize = 12.sp, color = if (row.mark == Mark.MISSED) GlanceTheme.colors.error else GlanceTheme.colors.onSurfaceVariant),
)
}
}
}

/** Taken: filled accent with a check. Due: accent ring. Missed: error ring. Skipped: muted ring. The text says the same. */
@Composable
private fun StatusMark(row: WidgetRow) {
val target = if (row.checkable) {
GlanceModifier.clickable(actionRunCallback<CheckAction>(actionParametersOf(CheckAction.KEY to row.key)))
.semantics { contentDescription = "Mark ${row.name} taken" }
} else {
GlanceModifier
}
Box(modifier = GlanceModifier.size(48.dp).then(target), contentAlignment = Alignment.Center) {
if (row.mark == Mark.TAKEN) {
Glyph(R.drawable.widget_check_dot, GlanceTheme.colors.primary, 28)
Glyph(R.drawable.widget_check, GlanceTheme.colors.onPrimary, 18)
} else {
val tint = when (row.mark) {
Mark.MISSED -> GlanceTheme.colors.error
Mark.SKIPPED -> GlanceTheme.colors.outline
else -> GlanceTheme.colors.primary
}
Glyph(R.drawable.widget_check_ring, tint, 28)
}
}
}

@Composable
private fun Glyph(drawable: Int, tint: ColorProvider, sizeDp: Int) {
Image(ImageProvider(drawable), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(sizeDp.dp))
}

/** The launcher's widget card: system corner radius and android:id/background, so launchers can restyle it. */
@Composable
private fun GlanceModifier.widgetCard(): GlanceModifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
appWidgetBackground().background(GlanceTheme.colors.widgetBackground).cornerRadius(android.R.dimen.system_app_widget_background_radius)
} else {
appWidgetBackground().background(ImageProvider(R.drawable.widget_background), colorFilter = ColorFilter.tint(GlanceTheme.colors.surfaceVariant))
}

companion object {
private val ROW = DpSize(110.dp, 40.dp)
private val CARD = DpSize(110.dp, 100.dp)
private val LIST = DpSize(180.dp, 150.dp)

suspend fun refresh(context: Context) = TodayWidget().updateAll(context)
}
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/src/main/res/drawable/widget_background.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget card: the launcher's corner radius on Android 12+. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_background" />
<corners android:radius="@dimen/widget_radius" />
</shape>
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/widget_check.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>
6 changes: 6 additions & 0 deletions app/src/main/res/drawable/widget_check_dot.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Filled circle behind the check of a done dose; tinted with the accent at runtime. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#FFFFFFFF" />
<size android:width="28dp" android:height="28dp" />
</shape>
5 changes: 3 additions & 2 deletions app/src/main/res/drawable/widget_check_ring.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Empty check ring for pending doses in the widget; tinted at runtime. -->
<!-- Empty check ring for doses still to log; tinted at runtime (accent, or error when missed). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#FFFFFFFF" />
<size android:width="36dp" android:height="36dp" />
<size android:width="28dp" android:height="28dp" />
</shape>
5 changes: 2 additions & 3 deletions app/src/main/res/layout/widget_loading.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/window_background"
tools:ignore="Overdraw" />
android:background="@drawable/widget_background" />
Loading
Loading