diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/widget/TodayWidget.kt b/app/src/main/kotlin/com/apollof/protocoltracker/widget/TodayWidget.kt index 6879a87..9846f1b 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/widget/TodayWidget.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/widget/TodayWidget.kt @@ -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 @@ -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 @@ -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, 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, done: Int) { - Column( - modifier = GlanceModifier.fillMaxSize().background(GlanceTheme.colors.background).cornerRadius(20.dp).padding(12.dp), - ) { - Row( - modifier = GlanceModifier.fillMaxWidth().clickable(actionStartActivity()), - 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(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()), + 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()), + 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())) { 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(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) } } diff --git a/app/src/main/res/drawable-nodpi/widget_preview_today.png b/app/src/main/res/drawable-nodpi/widget_preview_today.png new file mode 100644 index 0000000..4d99655 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_today.png differ diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..96ab816 --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_check.xml b/app/src/main/res/drawable/widget_check.xml new file mode 100644 index 0000000..2301219 --- /dev/null +++ b/app/src/main/res/drawable/widget_check.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/widget_check_dot.xml b/app/src/main/res/drawable/widget_check_dot.xml new file mode 100644 index 0000000..3cff6fd --- /dev/null +++ b/app/src/main/res/drawable/widget_check_dot.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_check_ring.xml b/app/src/main/res/drawable/widget_check_ring.xml index f691cc6..6cf4f9f 100644 --- a/app/src/main/res/drawable/widget_check_ring.xml +++ b/app/src/main/res/drawable/widget_check_ring.xml @@ -1,6 +1,7 @@ - + + - + diff --git a/app/src/main/res/layout/widget_loading.xml b/app/src/main/res/layout/widget_loading.xml index 7943b4f..dc6f349 100644 --- a/app/src/main/res/layout/widget_loading.xml +++ b/app/src/main/res/layout/widget_loading.xml @@ -1,7 +1,6 @@ + android:background="@drawable/widget_background" /> diff --git a/app/src/main/res/layout/widget_preview_today.xml b/app/src/main/res/layout/widget_preview_today.xml new file mode 100644 index 0000000..ca82d6a --- /dev/null +++ b/app/src/main/res/layout/widget_preview_today.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night-v31/widget.xml b/app/src/main/res/values-night-v31/widget.xml new file mode 100644 index 0000000..9b56ec8 --- /dev/null +++ b/app/src/main/res/values-night-v31/widget.xml @@ -0,0 +1,8 @@ + + + @android:color/system_accent2_800 + @android:color/system_accent1_200 + @android:color/system_accent1_800 + @android:color/system_neutral1_100 + @android:color/system_neutral2_200 + diff --git a/app/src/main/res/values-night/widget.xml b/app/src/main/res/values-night/widget.xml new file mode 100644 index 0000000..1cb2f90 --- /dev/null +++ b/app/src/main/res/values-night/widget.xml @@ -0,0 +1,9 @@ + + + #FF222826 + #FF7CCBB5 + #FF06251E + #FFE7ECE9 + #FF9AA59F + #FFF2B8B5 + diff --git a/app/src/main/res/values-v31/widget.xml b/app/src/main/res/values-v31/widget.xml new file mode 100644 index 0000000..9de2482 --- /dev/null +++ b/app/src/main/res/values-v31/widget.xml @@ -0,0 +1,10 @@ + + + + @android:color/system_accent2_50 + @android:color/system_accent1_600 + @android:color/system_accent1_0 + @android:color/system_neutral1_900 + @android:color/system_neutral2_700 + @android:dimen/system_app_widget_background_radius + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8b61bc..8e1664b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,14 @@ SteroidTracker Today Today\'s doses with one-tap check + + 1 of 3 + Vitamin D3 + Taken · Morning · 2000 IU + Omega-3 + Morning · 1 g + Magnesium + Evening · 400 mg Doses Reminder at each scheduled dose time Daily summary diff --git a/app/src/main/res/values/widget.xml b/app/src/main/res/values/widget.xml new file mode 100644 index 0000000..7721595 --- /dev/null +++ b/app/src/main/res/values/widget.xml @@ -0,0 +1,11 @@ + + + + #FFE8ECE9 + #FF1E6B5C + #FFFFFFFF + #FF151917 + #FF56605B + #FFB3261E + 20dp + diff --git a/app/src/main/res/xml/today_widget_info.xml b/app/src/main/res/xml/today_widget_info.xml index 9a84c9b..b58dcc1 100644 --- a/app/src/main/res/xml/today_widget_info.xml +++ b/app/src/main/res/xml/today_widget_info.xml @@ -1,12 +1,15 @@ + String): List = + missed.mapNotNull { e -> e.occurrence?.let { WidgetLine(e, dayLabel(it.localDate)) } } + + groups.flatMap { g -> g.entries.filter { it.occurrence != null }.map { WidgetLine(it, g.label) } } + +/** The dose the compact widget shows: the first one still to log, missed doses first. */ +fun List.nextDose(): WidgetLine? = firstOrNull { it.checkable } diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/WidgetLinesTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/WidgetLinesTest.kt new file mode 100644 index 0000000..b606926 --- /dev/null +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/WidgetLinesTest.kt @@ -0,0 +1,61 @@ +package com.apollof.protocoltracker.domain.schedule + +import com.apollof.protocoltracker.domain.model.Amount +import com.apollof.protocoltracker.domain.model.BaseUnit +import com.apollof.protocoltracker.domain.model.CompoundCategory +import com.apollof.protocoltracker.domain.model.DaySlot +import com.apollof.protocoltracker.domain.model.DoseLog +import com.apollof.protocoltracker.domain.model.DoseSnapshot +import com.apollof.protocoltracker.domain.model.DoseUnit +import com.apollof.protocoltracker.domain.model.LogStatus +import com.apollof.protocoltracker.domain.model.PlanItem +import com.apollof.protocoltracker.domain.model.Schedule +import com.apollof.protocoltracker.domain.model.Timing +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WidgetLinesTest { + private val zone = ZoneId.of("Europe/Amsterdam") + private val morning = PlanItem("m", null, "c", Amount(40.0, DoseUnit.MG), schedule = Schedule.Daily(listOf(Timing.Slot(DaySlot.MORNING))), sortOrder = 1) + private val pre = PlanItem("p", null, "c", Amount(50.0, DoseUnit.MG), schedule = Schedule.Daily(listOf(Timing.Slot(DaySlot.PRE_WORKOUT))), sortOrder = 2) + + private fun at(date: String, time: String) = ZonedDateTime.of(LocalDate.parse(date), LocalTime.parse(time), zone).toInstant() + private fun log(key: String, takenAt: Instant, status: LogStatus = LogStatus.TAKEN) = DoseLog( + key, "i", "c", key, null, takenAt, Amount(1.0, DoseUnit.MG), null, status, + snapshot = DoseSnapshot("C", "C", CompoundCategory.SUPPORT, BaseUnit.MG, null), createdAt = takenAt, + ) + + @Test + fun missedFirstThenTodayInPlanOrderWithDoneInPlace() { + val now = at("2026-09-25", "09:00") + val logs = listOf( + log(slotOccurrenceKey("m", LocalDate.parse("2026-09-25"), DaySlot.MORNING), at("2026-09-25", "08:10")), + log(slotOccurrenceKey("m", LocalDate.parse("2026-09-24"), DaySlot.MORNING), at("2026-09-24", "08:10")), + log(slotOccurrenceKey("m", LocalDate.parse("2026-09-23"), DaySlot.MORNING), at("2026-09-23", "08:10")), + ) + val lines = buildAgenda(emptyList(), listOf(morning, pre), logs, now, zone, IntervalAnchors.NONE) + .widgetLines { it.dayOfWeek.name.take(3) } + + assertEquals(listOf("WED", "THU", "Morning", "Pre-workout"), lines.map { it.label }) + assertEquals(listOf(true, true, false, true), lines.map { it.checkable }) + assertEquals(AgendaStatus.MISSED, lines.nextDose()!!.entry.status) + } + + @Test + fun nothingLeftHasNoNextDose() { + val now = at("2026-09-25", "09:00") + val key = slotOccurrenceKey("m", LocalDate.parse("2026-09-25"), DaySlot.MORNING) + val logs = listOf(log(key, at("2026-09-25", "08:10"), LogStatus.SKIPPED)) + val lines = buildAgenda(emptyList(), listOf(morning), logs, now, zone, IntervalAnchors.NONE).widgetLines { "" } + .filter { it.label == "Morning" } + + assertEquals(listOf(AgendaStatus.SKIPPED), lines.map { it.entry.status }) + assertNull(lines.nextDose()) + } +}