summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-06 09:31:03 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-06 09:31:03 +0000
commit702e0ea04984f203048d9a440bc6e1c8ba8b6d4e (patch)
tree6e789e37a9449972b3aecbc6553adaee343887da
parentab76bcae1324aadb6d163e565f5994d0db10ca61 (diff)
Widget: ship Material You theming, past-events restructuring, legibility fixes, new settings
This lands the color-theming work that had sat uncommitted since a prior session (2026-07-28) -- every build published in between stripped it out deliberately to avoid shipping unreviewed work -- plus a full round of fixes and new features layered on top since it finally shipped: Theming (WidgetPalette.kt, new): - 5 themes now: NEUTRAL/ACCENT/TONAL (wallpaper-derived via Material You), VIVID (new -- all three text roles pull from a different accent slot instead of anchoring primary to neutral, for real hue variety), CLASSIC (fixed, wallpaper-independent). - Settings picker redesigned to match Android's native wallpaper "Basic colors" circular swatches (bottom half + two top quadrants, filled with each theme's actual buildWidgetPalette() output, not an approximation). - Per-source accent colors (colored checkboxes/bars) fully removed from the grid. Past events (DootWidget.kt, WidgetRows.kt): - Already-ended-today events pulled out of the hourly grid, shown as list rows above it instead (matching how past tasks already float) -- fixes the grid's start hour getting stretched backward by stale events. Legibility (WidgetRows.kt): - ShadowedText upgraded from a single-corner drop shadow to a 4-corner halo/outline (protects all sides of a glyph, not just one). - Halo color now tracks each palette's text luminance (dark halo for light text, light halo for dark text) -- a hardcoded black halo behind already-dark light-mode text was doing essentially nothing. Bumped opacity 0.45/0.55 -> 0.65/0.7 as the cheap, low-risk strength dial. New settings (SettingsActivity.kt, DataStore.kt): - Background transparency slider (0-85%, default 0% unchanged) -- exposed for testing per explicit request, not a default change. - Hide-checkboxes toggle: drops the leading checkbox/dot element entirely (not just hides the icon) so task titles land flush with event titles; tap-to-complete-from-widget trades off for TaskDetailActivity's Complete button. Also: today's moon phase in the TODAY header (moonPhaseEmoji, pure date computation, no network) -- verified against direct calculation before writing test assertions, not hand-computed. Removed the unused glance-material3 dependency (verified zero usages before removing; turned out to save ~2KB, not the ~280KB expected, since material3 itself already pulls the same transitive deps -- noted honestly rather than oversold). Every new pure-logic piece has unit tests (isPastEvent, moonPhaseEmoji, theme construction) -- 59 total, all green. Verified installable and crash-free via a real API 36 emulator launch before each publish, not assumed. Deployed as doot-widget.apk.
-rw-r--r--android/app/build.gradle.kts6
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt4
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt67
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt178
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/WidgetPalette.kt176
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt300
-rw-r--r--android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt62
7 files changed, 685 insertions, 108 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 76f5b50..fb31aa8 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -68,7 +68,11 @@ dependencies {
implementation(composeBom)
implementation("androidx.glance:glance-appwidget:1.1.0")
- implementation("androidx.glance:glance-material3:1.1.0")
+ // glance-material3 removed 2026-08-06: zero usages anywhere in this codebase (no
+ // androidx.glance.material3 import, no GlanceTheme call) -- it only pulled in
+ // material-icons-core + material-ripple transitively, which are themselves unused
+ // too (no Compose Material Icons anywhere -- this app uses its own R.drawable
+ // vectors). Verified via `./gradlew :app:dependencies` before removing, not assumed.
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt b/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt
index 4b17687..f9e6680 100644
--- a/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -18,6 +19,9 @@ object Keys {
val LAST_UPDATED = longPreferencesKey("last_updated")
val IS_REFRESHING = booleanPreferencesKey("is_refreshing")
val TEXT_SIZE = stringPreferencesKey("text_size")
+ val COLOR_THEME = stringPreferencesKey("color_theme")
val BUDGET_STATUS_JSON = stringPreferencesKey("budget_status_json")
val PENDING_REMOVALS_JSON = stringPreferencesKey("pending_removals_json")
+ val BACKGROUND_ALPHA = floatPreferencesKey("background_alpha")
+ val HIDE_CHECKBOXES = booleanPreferencesKey("hide_checkboxes")
}
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
index 9f0a555..9793eb9 100644
--- 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
@@ -11,7 +11,6 @@ import androidx.glance.appwidget.lazy.LazyColumn
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 androidx.glance.unit.ColorProvider
import kotlinx.coroutines.flow.first
@@ -34,12 +33,16 @@ class DootWidget : GlanceAppWidget() {
?: Instant.now()
val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false
val textSize = WidgetTextSize.fromPref(prefs[Keys.TEXT_SIZE])
+ val colorTheme = WidgetColorTheme.fromPref(prefs[Keys.COLOR_THEME])
+ val palette = buildWidgetPalette(context, colorTheme)
+ val backgroundAlpha = prefs[Keys.BACKGROUND_ALPHA] ?: 0f
+ val hideCheckboxes = prefs[Keys.HIDE_CHECKBOXES] ?: false
val budgetStatus = prefs[Keys.BUDGET_STATUS_JSON]?.let {
runCatching { json.decodeFromString<BudgetStatus>(it) }.getOrNull()
}
provideContent {
- WidgetRoot(items, now, isRefreshing, textSize, budgetStatus)
+ WidgetRoot(items, now, isRefreshing, textSize, palette, backgroundAlpha, hideCheckboxes, budgetStatus)
}
}
@@ -50,7 +53,16 @@ class DootWidget : GlanceAppWidget() {
}
@Composable
-fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, textSize: WidgetTextSize, budgetStatus: BudgetStatus? = null) {
+fun WidgetRoot(
+ items: List<WidgetItem>,
+ now: Instant,
+ isRefreshing: Boolean,
+ textSize: WidgetTextSize,
+ palette: WidgetPalette,
+ backgroundAlpha: Float = 0f,
+ hideCheckboxes: Boolean = false,
+ budgetStatus: BudgetStatus? = null
+) {
val zone = ZoneId.systemDefault()
val nowZoned: ZonedDateTime = now.atZone(zone)
val todayDate: LocalDate = nowZoned.toLocalDate()
@@ -93,9 +105,18 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex
}
val allScheduled = rest.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) }
- // Past tasks float at now (before untimed tasks); past events stay in the grid at 50% alpha
+ // Past tasks float at now (before untimed tasks), same as untimed ones -- once past,
+ // a task is just a list item, not a grid slot. Past EVENTS get the identical
+ // treatment as of 2026-08-05 (see isPastEvent/TimeLabeledEventRow): pulled out of
+ // the grid entirely and shown as plain rows above it, alongside all-day events,
+ // instead of sitting faded inside their original hour slot. This also keeps a
+ // string of early-morning past events from stretching the grid's start hour back
+ // (see calcGridStart) long after they're no longer relevant.
val pastTasks = allScheduled.filter { it.type == "task" && Instant.parse(it.start!!) < now }
- val scheduledEvents = allScheduled.filter { it.type != "task" || Instant.parse(it.start!!) >= now }
+ val todayPastEvents = allScheduled.filter { isPastEvent(it, now) }
+ val scheduledEvents = allScheduled.filter {
+ (it.type != "task" || Instant.parse(it.start!!) >= now) && !isPastEvent(it, now)
+ }
val floating = rest.filter { it.start == null }
val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now)
// Grid bounds must only reflect TODAY's events -- a tomorrow event's hour-of-day
@@ -114,10 +135,14 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex
// Glance's LazyColumn (backed by a RemoteViews ListView) is what actually scrolls in an
// app widget — a plain Column clips its content to the widget's current height instead.
+ // Background stays Color.Black at backgroundAlpha (0f = fully transparent, the
+ // established default -- see feedback_widget_color_theming memory, "no card") --
+ // exposed as a user-controllable slider (2026-08-06) rather than a fixed decision,
+ // since the ask was explicitly to be able to try it, not to change the default.
LazyColumn(
modifier = GlanceModifier
.fillMaxSize()
- .background(Color.Transparent)
+ .background(Color.Black.copy(alpha = backgroundAlpha))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
item {
@@ -125,48 +150,56 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, tex
modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Text(
- "TODAY",
+ ShadowedText(
+ "${moonPhaseEmoji(todayDate)} TODAY",
style = TextStyle(
- color = ColorProvider(Color(0x99FFFFFF)),
+ color = ColorProvider(palette.textSecondary),
fontSize = textSize.scaledHeaderSize(11),
fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.defaultWeight()
)
budgetStatus?.today?.let { today ->
if (today.scheduledMinutes > 0) {
- Text(
+ ShadowedText(
"${today.scheduledMinutes}m/${today.availableMinutes}m",
style = TextStyle(
- color = ColorProvider(Color(0x99FFFFFF)),
+ color = ColorProvider(palette.textSecondary),
fontSize = textSize.scaledHeaderSize(10)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.padding(end = 4.dp)
)
}
}
- QuickAddButton()
+ QuickAddButton(palette)
Spacer(modifier = GlanceModifier.width(4.dp))
- RefreshButton(isRefreshing)
+ RefreshButton(isRefreshing, palette)
}
}
items(count = todayAllDay.size) { index ->
- AllDayRow(todayAllDay[index], textSize)
+ AllDayRow(todayAllDay[index], textSize, palette = palette)
}
items(count = todayMultiDay.size) { index ->
val (item, variant) = todayMultiDay[index]
- AllDayRow(item, textSize, variant)
+ AllDayRow(item, textSize, variant, palette)
+ }
+ items(count = todayPastEvents.size) { index ->
+ TimeLabeledEventRow(
+ todayPastEvents[index], zone, textSize, palette,
+ titleColor = palette.textPrimary, alpha = 0.5f
+ )
}
items(count = gridEnd - gridStart + 1) { index ->
- HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone, textSize)
+ HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone, textSize, palette, hideCheckboxes)
}
if (showTomorrow) {
item {
- TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, tomorrowMultiDay, zone, textSize)
+ TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, tomorrowMultiDay, zone, textSize, palette, hideCheckboxes)
}
}
}
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
index 393b371..44a7a20 100644
--- 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
@@ -5,12 +5,22 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
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.draw.clip
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -42,21 +52,30 @@ class SettingsActivity : ComponentActivity() {
var savedUrl by remember { mutableStateOf("") }
var savedToken by remember { mutableStateOf("") }
var savedTextSize by remember { mutableStateOf(WidgetTextSize.NORMAL) }
+ var savedColorTheme by remember { mutableStateOf(WidgetColorTheme.NEUTRAL) }
+ var savedBackgroundAlpha by remember { mutableStateOf(0f) }
+ var savedHideCheckboxes by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val prefs = dataStore.data.first()
savedUrl = prefs[Keys.SERVER_URL] ?: ""
savedToken = prefs[Keys.TOKEN] ?: ""
savedTextSize = WidgetTextSize.fromPref(prefs[Keys.TEXT_SIZE])
+ savedColorTheme = WidgetColorTheme.fromPref(prefs[Keys.COLOR_THEME])
+ savedBackgroundAlpha = prefs[Keys.BACKGROUND_ALPHA] ?: 0f
+ savedHideCheckboxes = prefs[Keys.HIDE_CHECKBOXES] ?: false
}
SettingsScreen(
initialUrl = savedUrl,
initialToken = savedToken,
initialTextSize = savedTextSize,
- onSave = { url, token, textSize ->
+ initialColorTheme = savedColorTheme,
+ initialBackgroundAlpha = savedBackgroundAlpha,
+ initialHideCheckboxes = savedHideCheckboxes,
+ onSave = { url, token, textSize, colorTheme, backgroundAlpha, hideCheckboxes ->
lifecycleScope.launch {
- saveAndFinish(url, token, textSize, appWidgetId)
+ saveAndFinish(url, token, textSize, colorTheme, backgroundAlpha, hideCheckboxes, appWidgetId)
}
},
onTest = { url, token, onResult ->
@@ -73,11 +92,22 @@ class SettingsActivity : ComponentActivity() {
}
}
- private suspend fun saveAndFinish(url: String, token: String, textSize: WidgetTextSize, appWidgetId: Int) {
+ private suspend fun saveAndFinish(
+ url: String,
+ token: String,
+ textSize: WidgetTextSize,
+ colorTheme: WidgetColorTheme,
+ backgroundAlpha: Float,
+ hideCheckboxes: Boolean,
+ appWidgetId: Int
+ ) {
dataStore.edit { prefs ->
prefs[Keys.SERVER_URL] = url.trimEnd('/')
prefs[Keys.TOKEN] = token
prefs[Keys.TEXT_SIZE] = textSize.name
+ prefs[Keys.COLOR_THEME] = colorTheme.name
+ prefs[Keys.BACKGROUND_ALPHA] = backgroundAlpha
+ prefs[Keys.HIDE_CHECKBOXES] = hideCheckboxes
}
RefreshWorker.schedule(this)
RefreshWorker.runOnce(this)
@@ -96,12 +126,18 @@ fun SettingsScreen(
initialUrl: String = "",
initialToken: String = "",
initialTextSize: WidgetTextSize = WidgetTextSize.NORMAL,
- onSave: (url: String, token: String, textSize: WidgetTextSize) -> Unit,
+ initialColorTheme: WidgetColorTheme = WidgetColorTheme.NEUTRAL,
+ initialBackgroundAlpha: Float = 0f,
+ initialHideCheckboxes: Boolean = false,
+ onSave: (url: String, token: String, textSize: WidgetTextSize, colorTheme: WidgetColorTheme, backgroundAlpha: Float, hideCheckboxes: Boolean) -> 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 textSize by remember(initialTextSize) { mutableStateOf(initialTextSize) }
+ var colorTheme by remember(initialColorTheme) { mutableStateOf(initialColorTheme) }
+ var backgroundAlpha by remember(initialBackgroundAlpha) { mutableStateOf(initialBackgroundAlpha) }
+ var hideCheckboxes by remember(initialHideCheckboxes) { mutableStateOf(initialHideCheckboxes) }
var status by remember { mutableStateOf("") }
var testing by remember { mutableStateOf(false) }
@@ -163,6 +199,79 @@ fun SettingsScreen(
}
}
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "Colors",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ WidgetColorTheme.entries.forEach { option ->
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ ColorThemeSwatch(
+ theme = option,
+ selected = colorTheme == option,
+ onClick = { colorTheme = option }
+ )
+ Text(
+ colorThemeLabel(option),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (colorTheme == option)
+ MaterialTheme.colorScheme.onSurface
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ "Background",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "${(backgroundAlpha * 100).toInt()}%",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ // 0% is the established "no card, transparent" default -- this is here so
+ // it can be tried, not because the default is expected to change.
+ Slider(
+ value = backgroundAlpha,
+ onValueChange = { backgroundAlpha = it },
+ valueRange = 0f..0.85f,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ "Hide checkboxes",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "Task titles line up with events. Complete from the task detail popup instead.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(checked = hideCheckboxes, onCheckedChange = { hideCheckboxes = it })
+ }
+
if (status.isNotEmpty()) {
Text(
status,
@@ -190,11 +299,70 @@ fun SettingsScreen(
}
Button(
- onClick = { onSave(url, token, textSize) },
+ onClick = { onSave(url, token, textSize, colorTheme, backgroundAlpha, hideCheckboxes) },
enabled = url.isNotBlank() && token.isNotBlank()
) {
Text("Save")
}
}
+
+ val context = LocalContext.current
+ OutlinedButton(
+ onClick = { context.startActivity(Intent(context, DashboardActivity::class.java)) },
+ enabled = url.isNotBlank(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Open Dashboard")
+ }
+ }
+}
+
+private fun colorThemeLabel(theme: WidgetColorTheme): String = when (theme) {
+ WidgetColorTheme.NEUTRAL -> "Neutral"
+ WidgetColorTheme.ACCENT -> "Accent"
+ WidgetColorTheme.TONAL -> "Tonal"
+ WidgetColorTheme.VIVID -> "Vivid"
+ WidgetColorTheme.CLASSIC -> "Classic"
+}
+
+/**
+ * A circular preview matching Android's native wallpaper "Basic colors" picker: a circle
+ * split by a horizontal diameter (bottom half) plus a vertical radius splitting the top
+ * half into two quadrants, each wedge filled with one of the theme's actual derived
+ * colors (via buildWidgetPalette, the same function the live widget renders with -- not
+ * an approximation of what the theme looks like). Selection is shown the same way the
+ * native picker does it: a ring around the swatch, not a background fill or checkmark.
+ */
+@Composable
+private fun ColorThemeSwatch(
+ theme: WidgetColorTheme,
+ selected: Boolean,
+ onClick: () -> Unit
+) {
+ val context = LocalContext.current
+ val palette = remember(theme) { buildWidgetPalette(context, theme) }
+ val ringColor = MaterialTheme.colorScheme.primary
+
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .then(if (selected) Modifier.border(2.dp, ringColor, CircleShape) else Modifier)
+ .clickable(onClick = onClick)
+ .semantics { contentDescription = colorThemeLabel(theme) },
+ contentAlignment = Alignment.Center
+ ) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(if (selected) 4.dp else 2.dp)
+ .clip(CircleShape)
+ ) {
+ val full = Size(size.width, size.height)
+ // drawArc: startAngle 0 = 3 o'clock, positive sweep = clockwise.
+ drawArc(color = palette.textPrimary, startAngle = 0f, sweepAngle = 180f, useCenter = true, size = full) // bottom half
+ drawArc(color = palette.textSecondary, startAngle = 180f, sweepAngle = 90f, useCenter = true, size = full) // top-left quadrant
+ drawArc(color = palette.nowLine, startAngle = 270f, sweepAngle = 90f, useCenter = true, size = full) // top-right quadrant
+ }
}
}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetPalette.kt b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetPalette.kt
new file mode 100644
index 0000000..9d1ec49
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetPalette.kt
@@ -0,0 +1,176 @@
+package org.terst.doot.widget.ui
+
+import android.content.Context
+import android.content.res.Configuration
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Chrome colors (body text, dividers) for the widget grid, rendered directly
+ * over the transparent home-screen background -- no card/background of its
+ * own. Per-source accent colors (see sourceColor() usage) are separate and
+ * not part of this palette.
+ */
+data class WidgetPalette(
+ val textPrimary: Color,
+ val textSecondary: Color,
+ val textMuted: Color,
+ val divider: Color,
+ val nowLine: Color,
+ // ShadowedText's halo needs to be the OPPOSITE tone of the text it's
+ // outlining -- a black halo behind already-dark text (every light-mode
+ // branch below: system_*_900/_700 shades) does essentially nothing,
+ // since dark-on-dark blends into itself regardless of what's behind it.
+ // Bug found 2026-08-06: this was hardcoded black in ShadowedText
+ // (correct for dark-mode/CLASSIC's light text, silently ineffective for
+ // every light-mode palette variant).
+ val haloColor: Color
+)
+
+enum class WidgetColorTheme {
+ /** Mostly neutral (grayscale) text; the wallpaper's accent hue shows only on the "now" line. */
+ NEUTRAL,
+ /** Primary/secondary text itself tinted by the wallpaper's dominant accent hue. */
+ ACCENT,
+ /** Primary text stays neutral for readability; secondary/muted text picks up the wallpaper's two complementary accent hues. */
+ TONAL,
+ /**
+ * All three text roles pull from a DIFFERENT one of the wallpaper's three
+ * accent slots (accent1/accent2/accent3), instead of anchoring primary to
+ * neutral (TONAL) or one hue everywhere (ACCENT) -- the most saturated,
+ * multi-hue option, added 2026-08-06 in response to "more diverse colors."
+ */
+ VIVID,
+ /** The widget's original fixed dark palette, regardless of wallpaper. */
+ CLASSIC;
+
+ companion object {
+ fun fromPref(raw: String?): WidgetColorTheme =
+ raw?.let { name -> entries.find { it.name == name } } ?: NEUTRAL
+ }
+}
+
+// Bumped 2026-08-06 (0.45/0.55 -> 0.65/0.7): "legibility is better but still
+// not great" after the corner-halo + luminance-matching fixes -- turning up
+// the one dial that's pure risk-free strength (opacity, not reach/geometry)
+// before reaching for a structurally different approach (true 8-neighbor
+// outline, or a backdrop behind the text).
+private val HALO_DARK = Color.Black.copy(alpha = 0.65f)
+private val HALO_LIGHT = Color.White.copy(alpha = 0.7f)
+
+// Grid lines (divider, now-line) are solid -- no alpha. Alpha over a
+// transparent widget blends unpredictably with whatever's under the
+// wallpaper at that spot instead of reading as a consistent line.
+private val CLASSIC_PALETTE = WidgetPalette(
+ textPrimary = Color.White,
+ textSecondary = Color.White.copy(alpha = 0.6f),
+ textMuted = Color.White.copy(alpha = 0.5f),
+ divider = Color(0xFF3D3D3D),
+ nowLine = Color(0xFFD9D9D9),
+ haloColor = HALO_DARK
+)
+
+/**
+ * Builds the widget's chrome palette from the wallpaper-derived
+ * system_neutral/accent color resources (Material You / "Monet", API 31+ --
+ * minSdk is 36, so these always exist). No alpha tweaking on top of any
+ * resource color -- a "muted" role means picking a lower-contrast shade
+ * number, not adding transparency over a higher-contrast one, and grid
+ * lines are always fully opaque for the same reason as CLASSIC's. Light vs.
+ * dark shades are chosen from the system's current dark-theme setting so
+ * text stays legible either way.
+ *
+ * Divider shade picked for LOW contrast against its own background, not
+ * high visibility -- a hairline should read as subtle structure, not a
+ * bold rule. neutral2_700 in dark mode sits close to the near-black
+ * background; neutral2_100 in light mode sits close to the near-white one.
+ * (Was neutral2_400/200, which read as too heavy against the background --
+ * corrected 2026-08-05.)
+ */
+fun buildWidgetPalette(context: Context, theme: WidgetColorTheme): WidgetPalette {
+ if (theme == WidgetColorTheme.CLASSIC) return CLASSIC_PALETTE
+
+ val isNight = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
+ Configuration.UI_MODE_NIGHT_YES
+
+ fun sysColor(resId: Int): Color = Color(context.getColor(resId))
+
+ return when (theme) {
+ WidgetColorTheme.ACCENT -> if (isNight) {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_accent1_100),
+ textSecondary = sysColor(android.R.color.system_accent1_300),
+ textMuted = sysColor(android.R.color.system_neutral2_500),
+ divider = sysColor(android.R.color.system_neutral2_700),
+ nowLine = sysColor(android.R.color.system_accent2_200),
+ haloColor = HALO_DARK
+ )
+ } else {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_accent1_900),
+ textSecondary = sysColor(android.R.color.system_accent1_700),
+ textMuted = sysColor(android.R.color.system_neutral2_500),
+ divider = sysColor(android.R.color.system_neutral2_100),
+ nowLine = sysColor(android.R.color.system_accent2_600),
+ haloColor = HALO_LIGHT
+ )
+ }
+ WidgetColorTheme.TONAL -> if (isNight) {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_neutral1_50),
+ textSecondary = sysColor(android.R.color.system_accent2_200),
+ textMuted = sysColor(android.R.color.system_accent3_300),
+ divider = sysColor(android.R.color.system_neutral2_700),
+ nowLine = sysColor(android.R.color.system_accent1_200),
+ haloColor = HALO_DARK
+ )
+ } else {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_neutral1_900),
+ textSecondary = sysColor(android.R.color.system_accent2_700),
+ textMuted = sysColor(android.R.color.system_accent3_600),
+ divider = sysColor(android.R.color.system_neutral2_100),
+ nowLine = sysColor(android.R.color.system_accent1_600),
+ haloColor = HALO_LIGHT
+ )
+ }
+ WidgetColorTheme.VIVID -> if (isNight) {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_accent1_200),
+ textSecondary = sysColor(android.R.color.system_accent2_200),
+ textMuted = sysColor(android.R.color.system_accent3_300),
+ divider = sysColor(android.R.color.system_neutral2_700),
+ nowLine = sysColor(android.R.color.system_accent3_200),
+ haloColor = HALO_DARK
+ )
+ } else {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_accent1_700),
+ textSecondary = sysColor(android.R.color.system_accent2_700),
+ textMuted = sysColor(android.R.color.system_accent3_600),
+ divider = sysColor(android.R.color.system_neutral2_100),
+ nowLine = sysColor(android.R.color.system_accent3_600),
+ haloColor = HALO_LIGHT
+ )
+ }
+ // NEUTRAL, and the fallback for any unrecognized pref value (see fromPref).
+ else -> if (isNight) {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_neutral1_50),
+ textSecondary = sysColor(android.R.color.system_neutral2_300),
+ textMuted = sysColor(android.R.color.system_neutral2_500),
+ divider = sysColor(android.R.color.system_neutral2_700),
+ nowLine = sysColor(android.R.color.system_accent1_200),
+ haloColor = HALO_DARK
+ )
+ } else {
+ WidgetPalette(
+ textPrimary = sysColor(android.R.color.system_neutral1_900),
+ textSecondary = sysColor(android.R.color.system_neutral2_700),
+ textMuted = sysColor(android.R.color.system_neutral2_500),
+ divider = sysColor(android.R.color.system_neutral2_100),
+ nowLine = sysColor(android.R.color.system_accent1_600),
+ haloColor = HALO_LIGHT
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
index 750dc7b..8ac1f91 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
@@ -17,6 +17,7 @@ import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import org.terst.doot.widget.data.*
import java.time.Instant
+import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
@@ -36,6 +37,74 @@ internal fun parseGlanceHexColor(hex: String): Color = runCatching {
Color(android.graphics.Color.parseColor(hex))
}.getOrDefault(Color.Gray)
+private val HALO_INSET = 1.dp
+private val HALO_SPAN = 2.dp
+
+/**
+ * Glance's TextStyle has no shadow parameter (checked through 1.3.0-alpha02,
+ * the latest available build) and RemoteViews doesn't expose
+ * TextView.setShadowLayer as a remotable action, so there's no direct API
+ * for a text drop shadow in an app widget. Faked instead by stacking four
+ * copies of the text at the four diagonal corners around the real text,
+ * forming a halo/outline -- not a single offset drop shadow.
+ *
+ * A single-corner shadow (the original version of this, 2026-07-28) only
+ * protects legibility on the one side it's offset toward; the other three
+ * sides of every glyph get no help, so text over a busy/light region of the
+ * wallpaper on those sides still washes out (reported 2026-08-06). Four
+ * corners is the "poor man's text outline" technique long used for exactly
+ * this class of problem (subtitles, map labels, game HUD text) wherever the
+ * background can't be controlled or blurred. True N/S/E/W-neighbor outline
+ * would be marginally more even, but Glance's Box has no negative-padding
+ * or per-child-alignment primitive to express that -- only "push further
+ * from the shared top-start anchor" -- so diagonal corners is what's
+ * actually achievable with the stacking trick already in use here, and it
+ * reads the same as true 4-neighbor at this offset distance.
+ *
+ * [haloColor] must be passed by the caller (usually palette.haloColor), not
+ * hardcoded here: a dark halo behind already-dark text (every light-mode
+ * palette variant, which uses system_*_900/_700 shades) does essentially
+ * nothing, since dark-on-dark blends into itself regardless of the
+ * wallpaper behind it. Bug found 2026-08-06 -- this used to be a module-level
+ * constant, always black, silently ineffective for half the themes.
+ *
+ * Cost: 5 Text nodes per call instead of 2, i.e. every call site's share of
+ * the widget's RemoteViews node count goes up 2.5x. Verified this doesn't
+ * cause rendering problems by building and installing on a real device
+ * emulator rather than assuming -- if a future change adds enough more text
+ * rows that this becomes a real concern, drop to 2 opposite corners before
+ * reverting to a single-side shadow.
+ */
+@Composable
+fun ShadowedText(
+ text: String,
+ style: TextStyle,
+ haloColor: Color,
+ modifier: GlanceModifier = GlanceModifier,
+ maxLines: Int = Int.MAX_VALUE
+) {
+ val shadowStyle = style.copy(color = ColorProvider(haloColor))
+ Box(modifier = modifier) {
+ Text(text = text, style = shadowStyle, maxLines = maxLines) // top-left corner
+ Text(
+ text = text, style = shadowStyle, maxLines = maxLines,
+ modifier = GlanceModifier.padding(start = HALO_SPAN) // top-right corner
+ )
+ Text(
+ text = text, style = shadowStyle, maxLines = maxLines,
+ modifier = GlanceModifier.padding(top = HALO_SPAN) // bottom-left corner
+ )
+ Text(
+ text = text, style = shadowStyle, maxLines = maxLines,
+ modifier = GlanceModifier.padding(start = HALO_SPAN, top = HALO_SPAN) // bottom-right corner
+ )
+ Text(
+ text = text, style = style, maxLines = maxLines,
+ modifier = GlanceModifier.padding(start = HALO_INSET, top = HALO_INSET) // real text, centered
+ )
+ }
+}
+
/** Opens the event's source URL (Google Calendar, Plan to Eat, etc.) directly. */
fun calendarViewIntent(url: String): Intent =
Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })).apply {
@@ -43,8 +112,7 @@ fun calendarViewIntent(url: String): Intent =
}
@Composable
-fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVariant = MultiDayVariant.NONE) {
- val color = sourceColor(event.source)
+fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVariant = MultiDayVariant.NONE, palette: WidgetPalette) {
val label = multiDayLabel(event, variant)
Row(
modifier = GlanceModifier
@@ -53,30 +121,31 @@ fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVari
.clickable(actionStartActivity(calendarViewIntent(event.url))),
verticalAlignment = Alignment.CenterVertically
) {
- // Matches HourRow's 32dp hour-label gutter so this row's color bar lines up
- // with EventBlock's bar in the grid below it, instead of sitting flush left.
+ // Matches HourRow's 32dp hour-label gutter so this row's text lines up
+ // with EventBlock's text in the grid below it, instead of sitting flush left.
Spacer(modifier = GlanceModifier.width(32.dp))
- Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
- Text(
+ ShadowedText(
text = event.title,
style = TextStyle(
- color = ColorProvider(Color.White.copy(alpha = 0.9f)),
+ color = ColorProvider(palette.textPrimary),
fontSize = textSize.scaledContentSize(13),
fontWeight = textSize.scaledContentWeight(FontWeight.Medium)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.padding(start = 8.dp),
maxLines = 1
)
if (label.isNotEmpty()) {
- Text(
+ ShadowedText(
// Full opacity, matching EventBlock's upcoming (non-past) events --
// the label is time-relevant info, not secondary chrome.
text = label,
style = TextStyle(
- color = ColorProvider(Color.White.copy(alpha = 1f)),
+ color = ColorProvider(palette.textPrimary),
fontSize = textSize.scaledContentSize(13),
fontWeight = textSize.scaledContentWeight(FontWeight.Medium)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.padding(start = 4.dp),
maxLines = 1
)
@@ -85,7 +154,7 @@ fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVari
}
@Composable
-fun RefreshButton(isRefreshing: Boolean) {
+fun RefreshButton(isRefreshing: Boolean, palette: WidgetPalette) {
Box(
modifier = GlanceModifier
.size(24.dp)
@@ -98,14 +167,14 @@ fun RefreshButton(isRefreshing: Boolean) {
else org.terst.doot.widget.R.drawable.ic_refresh
),
contentDescription = if (isRefreshing) "Refreshing" else "Refresh",
- colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))),
+ colorFilter = ColorFilter.tint(ColorProvider(palette.textSecondary)),
modifier = GlanceModifier.size(14.dp)
)
}
}
@Composable
-fun QuickAddButton() {
+fun QuickAddButton(palette: WidgetPalette) {
val context = LocalContext.current
val intent = Intent(context, QuickAddActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -119,7 +188,7 @@ fun QuickAddButton() {
Image(
provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_add),
contentDescription = "Add task",
- colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))),
+ colorFilter = ColorFilter.tint(ColorProvider(palette.textSecondary)),
modifier = GlanceModifier.size(14.dp)
)
}
@@ -132,7 +201,9 @@ fun HourRow(
scheduled: List<WidgetItem>,
fragments: List<TaskFragment>,
@Suppress("UNUSED_PARAMETER") zone: ZoneId,
- textSize: WidgetTextSize
+ textSize: WidgetTextSize,
+ palette: WidgetPalette,
+ hideCheckboxes: Boolean = false
) {
val hourStart = nowZoned.withHour(hour).withMinute(0).withSecond(0).withNano(0).toInstant()
val hourEnd = hourStart.plus(1, ChronoUnit.HOURS)
@@ -152,19 +223,20 @@ fun HourRow(
modifier = GlanceModifier.fillMaxWidth().wrapContentHeight(),
verticalAlignment = Alignment.Top
) {
- Text(
+ ShadowedText(
text = hourLabel(hour),
style = TextStyle(
- color = ColorProvider(Color(0x80FFFFFF)),
+ color = ColorProvider(palette.textMuted),
fontSize = textSize.scaledHeaderSize(10),
fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.width(32.dp).padding(top = 2.dp)
)
Column(modifier = GlanceModifier.defaultWeight()) {
// Hour divider line
- Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(Color(0x1AFFFFFF))) {}
+ Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(palette.divider)) {}
// NOW indicator
if (isNowHour) {
@@ -172,27 +244,27 @@ fun HourRow(
modifier = GlanceModifier
.fillMaxWidth()
.height(1.dp)
- .background(Color(0xBFFFFFFF.toInt()))
+ .background(palette.nowLine)
.padding(vertical = 2.dp)
) {}
}
+ // Past events never reach here -- WidgetRoot pulls them out of `scheduled`
+ // before this point (see isPastEvent) and renders them as list rows above
+ // the grid instead, the same way past tasks already float above it.
eventsHere.forEach { event ->
- val isPast = event.end?.let { Instant.parse(it) < nowZoned.toInstant() } ?: false
- EventBlock(event, isPast, textSize)
+ EventBlock(event, textSize, palette)
}
fragsHere.forEach { frag ->
- TaskFragmentBlock(frag, textSize)
+ TaskFragmentBlock(frag, textSize, palette, hideCheckboxes)
}
}
}
}
@Composable
-fun EventBlock(event: WidgetItem, isPast: Boolean, textSize: WidgetTextSize) {
- val color = sourceColor(event.source)
- val alpha = if (isPast) 0.5f else 1f
+fun EventBlock(event: WidgetItem, textSize: WidgetTextSize, palette: WidgetPalette) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
@@ -200,19 +272,14 @@ fun EventBlock(event: WidgetItem, isPast: Boolean, textSize: WidgetTextSize) {
.clickable(actionStartActivity(calendarViewIntent(event.url))),
verticalAlignment = Alignment.CenterVertically
) {
- Box(
- modifier = GlanceModifier
- .width(3.dp)
- .height(20.dp)
- .background(color.copy(alpha = alpha))
- ) {}
- Text(
+ ShadowedText(
text = event.title,
style = TextStyle(
- color = ColorProvider(Color.White.copy(alpha = alpha)),
+ color = ColorProvider(palette.textPrimary),
fontSize = textSize.scaledContentSize(14),
fontWeight = textSize.scaledContentWeight(FontWeight.Normal)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.padding(start = 8.dp),
maxLines = 1
)
@@ -220,20 +287,20 @@ fun EventBlock(event: WidgetItem, isPast: Boolean, textSize: WidgetTextSize) {
}
@Composable
-fun TaskFragmentBlock(fragment: TaskFragment, textSize: WidgetTextSize) {
+fun TaskFragmentBlock(fragment: TaskFragment, textSize: WidgetTextSize, palette: WidgetPalette, hideCheckboxes: Boolean = false) {
Column(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
fragment.slots.forEach { slot ->
- TaskRow(slot.task, textSize)
+ TaskRow(slot.task, textSize, palette, hideCheckboxes)
}
}
}
@Composable
-fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) {
+fun TaskRow(task: WidgetItem, textSize: WidgetTextSize, palette: WidgetPalette, hideCheckboxes: Boolean = false) {
val context = LocalContext.current
val detailIntent = Intent(context, TaskDetailActivity::class.java).apply {
putExtra(TaskDetailActivity.EXTRA_ID, task.id)
@@ -252,33 +319,41 @@ fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) {
.padding(horizontal = 8.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
- if (task.completable) {
- Box(
- modifier = GlanceModifier
- .size(24.dp)
- .clickable(
- actionRunCallback<CompleteTaskAction>(
- actionParametersOf(
- CompleteTaskAction.idKey to task.id,
- CompleteTaskAction.sourceKey to task.source
+ // Suppressing checkboxes drops this leading element entirely rather than just
+ // hiding its icon -- the title Box below already carries its own 8dp start
+ // padding (matching EventBlock's), so with nothing ahead of it in the Row,
+ // task titles land flush with event titles instead of offset by unused space.
+ // Tradeoff: tap-to-complete-from-the-widget goes with it (TaskDetailActivity's
+ // Complete button still works), which is what "so everything lines up" implies.
+ if (!hideCheckboxes) {
+ if (task.completable) {
+ Box(
+ modifier = GlanceModifier
+ .size(24.dp)
+ .clickable(
+ actionRunCallback<CompleteTaskAction>(
+ actionParametersOf(
+ CompleteTaskAction.idKey to task.id,
+ CompleteTaskAction.sourceKey to task.source
+ )
)
- )
- ),
- contentAlignment = Alignment.Center
- ) {
- 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)
- )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_checkbox_empty),
+ contentDescription = "Complete ${task.title}",
+ colorFilter = ColorFilter.tint(ColorProvider(palette.textSecondary)),
+ modifier = GlanceModifier.size(14.dp)
+ )
+ }
+ } else {
+ Box(
+ modifier = GlanceModifier
+ .size(8.dp)
+ .background(palette.textMuted)
+ ) {}
}
- } else {
- Box(
- modifier = GlanceModifier
- .size(8.dp)
- .background(sourceColor(task.source).copy(alpha = 0.6f))
- ) {}
}
if (task.projectColor != null) {
@@ -297,24 +372,26 @@ fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) {
.padding(start = 8.dp),
contentAlignment = Alignment.CenterStart
) {
- Text(
+ ShadowedText(
text = task.title,
style = TextStyle(
- color = ColorProvider(Color.White),
+ color = ColorProvider(palette.textPrimary),
fontSize = textSize.scaledContentSize(14),
fontWeight = textSize.scaledContentWeight(FontWeight.Normal)
),
+ haloColor = palette.haloColor,
maxLines = 1
)
}
if (task.chainTotal > 0) {
- Text(
+ ShadowedText(
text = "${task.chainPosition}/${task.chainTotal}",
style = TextStyle(
- color = ColorProvider(Color(0xFFC4B5FD)),
+ color = ColorProvider(palette.textMuted),
fontSize = textSize.scaledContentSize(11)
),
+ haloColor = palette.haloColor,
modifier = GlanceModifier.padding(start = 4.dp)
)
}
@@ -329,12 +406,13 @@ fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) {
)
)
) {
- Text(
+ ShadowedText(
text = "defer",
style = TextStyle(
- color = ColorProvider(Color(0x99FFFFFF)),
+ color = ColorProvider(palette.textSecondary),
fontSize = textSize.scaledContentSize(11)
- )
+ ),
+ haloColor = palette.haloColor
)
}
}
@@ -348,7 +426,9 @@ fun TomorrowSection(
allDayEvents: List<WidgetItem>,
multiDayEvents: List<Pair<WidgetItem, MultiDayVariant>>,
zone: ZoneId,
- textSize: WidgetTextSize
+ textSize: WidgetTextSize,
+ palette: WidgetPalette,
+ hideCheckboxes: Boolean = false
) {
// This whole section renders inside a single LazyColumn `item { }` slot
// (see DootWidget.kt), which composes to one RemoteViews node -- without
@@ -356,40 +436,56 @@ fun TomorrowSection(
// no shared vertical-flow container and all stack at the same position
// instead of flowing top-to-bottom.
Column(modifier = GlanceModifier.fillMaxWidth()) {
- Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {}
+ Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(palette.divider)) {}
Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) {
- Text(
+ ShadowedText(
"TOMORROW",
style = TextStyle(
- color = ColorProvider(Color(0x99FFFFFF)),
+ color = ColorProvider(palette.textSecondary),
fontSize = textSize.scaledHeaderSize(11),
fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold)
- )
+ ),
+ haloColor = palette.haloColor
)
}
- allDayEvents.forEach { AllDayRow(it, textSize) }
- multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant) }
+ allDayEvents.forEach { AllDayRow(it, textSize, palette = palette) }
+ multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant, palette) }
items.forEach { item ->
- val isPast = false
if (item.type == "event") {
- TomorrowEventRow(item, zone, textSize)
+ TimeLabeledEventRow(item, zone, textSize, palette, titleColor = palette.textSecondary)
} else {
- TaskRow(item, textSize)
+ TaskRow(item, textSize, palette, hideCheckboxes)
}
}
fragments.forEach { frag ->
- frag.slots.forEach { slot -> TaskRow(slot.task, textSize) }
+ frag.slots.forEach { slot -> TaskRow(slot.task, textSize, palette, hideCheckboxes) }
}
}
}
+/**
+ * A time-label + title row for an event shown outside the hourly grid --
+ * used for tomorrow's events (no grid exists past today) and, since the
+ * 2026-08-05 "past events shouldn't stretch the grid" change, for today's
+ * already-ended events too (same treatment past tasks already got: once
+ * past, it's just a list item, not a grid slot). [titleColor]/[alpha] let
+ * each caller keep its own existing look -- tomorrow's events are
+ * de-emphasized via textSecondary at full opacity; today's past events
+ * keep EventBlock's previous textPrimary-at-50%-alpha treatment.
+ */
@Composable
-fun TomorrowEventRow(event: WidgetItem, zone: ZoneId, textSize: WidgetTextSize) {
- val color = sourceColor(event.source)
+fun TimeLabeledEventRow(
+ event: WidgetItem,
+ zone: ZoneId,
+ textSize: WidgetTextSize,
+ palette: WidgetPalette,
+ titleColor: Color,
+ alpha: Float = 1f
+) {
val timeLabel = event.start?.let {
val t = Instant.parse(it).atZone(zone)
hourLabel(t.hour) + if (t.minute > 0) t.minute.toString().padStart(2, '0') else ""
@@ -401,29 +497,63 @@ fun TomorrowEventRow(event: WidgetItem, zone: ZoneId, textSize: WidgetTextSize)
.clickable(actionStartActivity(calendarViewIntent(event.url))),
verticalAlignment = Alignment.CenterVertically
) {
- Text(
+ ShadowedText(
text = timeLabel,
style = TextStyle(
- color = ColorProvider(Color(0x80FFFFFF)),
+ color = ColorProvider(palette.textMuted.copy(alpha = alpha)),
fontSize = textSize.scaledHeaderSize(10),
fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal)
),
+ haloColor = palette.haloColor.copy(alpha = palette.haloColor.alpha * alpha),
modifier = GlanceModifier.width(32.dp)
)
- Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
- Text(
+ ShadowedText(
text = event.title,
style = TextStyle(
- color = ColorProvider(Color.White.copy(alpha = 0.75f)),
+ color = ColorProvider(titleColor.copy(alpha = alpha)),
fontSize = textSize.scaledContentSize(13),
fontWeight = textSize.scaledContentWeight(FontWeight.Normal)
),
+ haloColor = palette.haloColor.copy(alpha = palette.haloColor.alpha * alpha),
modifier = GlanceModifier.padding(start = 8.dp),
maxLines = 1
)
}
}
+/**
+ * True for a calendar event (never a task -- past tasks are already handled
+ * separately by SlotPacker) whose end time has passed. Matches the "past"
+ * semantics EventBlock used to compute inline before the 2026-08-05 change
+ * that pulls past events out of the grid entirely: an event with no end
+ * time is never past (still ongoing/current, not stale).
+ */
+internal fun isPastEvent(item: WidgetItem, now: Instant): Boolean {
+ if (item.type == "task") return false
+ val end = item.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false
+ return end < now
+}
+
+private val MOON_PHASES = listOf("🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘") // 🌑🌒🌓🌔🌕🌖🌗🌘
+private val KNOWN_NEW_MOON: LocalDate = LocalDate.of(2000, 1, 6) // widely-used reference new moon (18:14 UTC)
+private const val SYNODIC_MONTH_DAYS = 29.53058867
+
+/**
+ * Today's moon phase, purely computed from the date -- no network call, no
+ * API key, just days-since-a-known-new-moon modulo the synodic month.
+ * Accurate to within about a day, which is the point: a small, honestly-fun
+ * touch for the "TODAY" header, not a navigation instrument. (Requested
+ * 2026-08-06 as "do something fun and surprising" -- picked this because
+ * it's a nice detail widgets basically never have, actually useful if you
+ * sail, and costs nothing to compute or maintain.)
+ */
+internal fun moonPhaseEmoji(date: LocalDate): String {
+ val daysSince = ChronoUnit.DAYS.between(KNOWN_NEW_MOON, date).toDouble()
+ val phase = ((daysSince % SYNODIC_MONTH_DAYS) + SYNODIC_MONTH_DAYS) % SYNODIC_MONTH_DAYS
+ val index = ((phase / SYNODIC_MONTH_DAYS) * MOON_PHASES.size).toInt().coerceIn(0, MOON_PHASES.size - 1)
+ return MOON_PHASES[index]
+}
+
internal fun hourLabel(hour: Int): String = when {
hour == 0 -> "12a"
hour < 12 -> "${hour}a"
diff --git a/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt b/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt
index e3d76b1..550c533 100644
--- a/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt
+++ b/android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt
@@ -1,10 +1,13 @@
package org.terst.doot.widget.ui
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.After
import org.junit.Test
import org.terst.doot.widget.data.WidgetItem
+import java.time.Instant
import java.util.TimeZone
class DootWidgetGridTest {
@@ -47,4 +50,63 @@ class DootWidgetGridTest {
val gridEnd = calcGridEnd(emptyList(), nowHour = 22)
assertEquals(23, gridEnd)
}
+
+ // isPastEvent: 2026-08-05 change pulling already-ended events out of the grid to
+ // stack above it as list rows, same treatment past tasks already got.
+
+ private fun event(endIso: String?) = WidgetItem(
+ id = "e", title = "Event", source = "calendar", type = "event",
+ start = "2026-07-12T09:00:00Z", end = endIso
+ )
+
+ private fun task(startIso: String) = WidgetItem(
+ id = "t", title = "Task", source = "doot", type = "task", start = startIso
+ )
+
+ @Test
+ fun `isPastEvent is true once end has passed`() {
+ val now = Instant.parse("2026-07-12T10:00:00Z")
+ assertTrue(isPastEvent(event(endIso = "2026-07-12T09:30:00Z"), now))
+ }
+
+ @Test
+ fun `isPastEvent is false while still ongoing`() {
+ val now = Instant.parse("2026-07-12T10:00:00Z")
+ assertFalse(isPastEvent(event(endIso = "2026-07-12T10:30:00Z"), now))
+ }
+
+ @Test
+ fun `isPastEvent is false with no end time -- never considered past`() {
+ val now = Instant.parse("2026-07-12T23:00:00Z")
+ assertFalse(isPastEvent(event(endIso = null), now))
+ }
+
+ @Test
+ fun `isPastEvent is always false for tasks -- SlotPacker already handles those`() {
+ val now = Instant.parse("2026-07-12T10:00:00Z")
+ assertFalse(isPastEvent(task(startIso = "2026-07-12T08:00:00Z"), now))
+ }
+
+ // moonPhaseEmoji: values verified against direct computation before writing these
+ // assertions (days-since-anchor / synodic-month arithmetic), not hand-computed.
+
+ @Test
+ fun `moonPhaseEmoji on the reference new moon date is new moon`() {
+ assertEquals("🌑", moonPhaseEmoji(java.time.LocalDate.of(2000, 1, 6)))
+ }
+
+ @Test
+ fun `moonPhaseEmoji about half a synodic month later is full moon`() {
+ assertEquals("🌕", moonPhaseEmoji(java.time.LocalDate.of(2000, 1, 21)))
+ }
+
+ @Test
+ fun `moonPhaseEmoji never indexes out of bounds across a full cycle`() {
+ val anchor = java.time.LocalDate.of(2000, 1, 6)
+ for (offset in 0..365) {
+ // Must not throw for any date in a full year -- the real assertion here is
+ // "doesn't crash," the emoji values themselves are checked in the two tests above.
+ moonPhaseEmoji(anchor.plusDays(offset.toLong()))
+ }
+ }
}