P3 Stream K: Glance widgets (small/medium/large) matching iOS

- HoneyDueSmallWidget (2x2), HoneyDueMediumWidget (4x2),
  HoneyDueLargeWidget (4x4) rewritten to consume the
  iOS-parity WidgetDataRepository (Stream J).
- Free-tier shows a large count-only layout (matches iOS
  FreeWidgetView); premium shows task list + complete buttons
  (Large widget also renders the Overdue / 7 Days / 30 Days
  stats row from WidgetDataRepository.computeStats()).
- WidgetFormatter mirrors iOS formatWidgetDate: "Today" /
  "in N day(s)" / "N day(s) ago".
- WidgetColors maps priority levels (1-4) to primary/yellow/
  accent/error, matching iOS OrganicTaskRowView.priorityColor.
- WidgetUi shared Glance composables (TaskRow, WidgetHeader,
  EmptyState, TaskCountBlock, StatPill, StatsRow, CompleteButton)
  wired to Stream M's CompleteTaskAction for interactive rows.
- JVM tests: WidgetFormatterTest + WidgetColorsTest covering
  10 formatter assertions and 11 color mapping assertions.

Glance caveats: no radial/linear gradients or custom shapes, so
iOS's "organic" glows are dropped in favor of cream background +
tinted TaskRow cards. Colors, typography, and priority semantics
match iOS exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:55:08 -05:00
parent dbff329384
commit 1fcb456ef1
8 changed files with 888 additions and 598 deletions
@@ -3,151 +3,110 @@ package com.tt.honeyDue.widget
import android.content.Context
import android.content.Intent
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.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
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.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import com.tt.honeyDue.R
/**
* Small widget showing task count summary
* Size: 2x1 or 2x2
* Small (2x2) widget.
*
* Mirrors iOS `SmallWidgetView` / `FreeWidgetView` in
* `iosApp/HoneyDue/HoneyDue.swift`:
* - Free tier → big count + "tasks waiting" label.
* - Premium → task count header + single next-task row with
* an inline complete button wired to [CompleteTaskAction].
*
* Glance restriction: no radial gradients or custom shapes, so the
* "organic" glow behind the number is dropped. Cream background and
* primary/accent colors match iOS.
*/
class HoneyDueSmallWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override val sizeMode: SizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
val tasks = repo.loadTasks()
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
provideContent {
GlanceTheme {
SmallWidgetContent()
}
SmallWidgetContent(tasks, isPremium)
}
}
@Composable
private fun SmallWidgetContent() {
val prefs = currentState<Preferences>()
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
private fun SmallWidgetContent(
tasks: List<WidgetTaskDto>,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color(0xFFFFF8E7)) // Cream background
.clickable(actionRunCallback<OpenAppAction>())
.padding(12.dp),
.background(WidgetColors.BACKGROUND_PRIMARY)
.padding(12.dp)
.clickable(openApp),
contentAlignment = Alignment.Center
) {
Column(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// App name/logo
Text(
text = "honeyDue",
style = TextStyle(
color = ColorProvider(Color(0xFF07A0C3)),
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.height(8.dp))
// Task counts row
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
if (!isPremium) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
modifier = GlanceModifier.fillMaxSize()
) {
// Overdue
TaskCountItem(
count = overdueCount,
label = "Overdue",
color = Color(0xFFDD1C1A) // Red
)
TaskCountBlock(count = tasks.size, long = true)
}
} else {
Column(modifier = GlanceModifier.fillMaxSize()) {
TaskCountBlock(count = tasks.size, long = false)
Spacer(modifier = GlanceModifier.width(16.dp))
Spacer(modifier = GlanceModifier.height(8.dp))
// Due Soon
TaskCountItem(
count = dueSoonCount,
label = "Due Soon",
color = Color(0xFFF5A623) // Amber
)
Spacer(modifier = GlanceModifier.width(16.dp))
// In Progress
TaskCountItem(
count = inProgressCount,
label = "Active",
color = Color(0xFF07A0C3) // Primary
)
val nextTask = tasks.firstOrNull()
if (nextTask != null) {
TaskRow(
task = nextTask,
compact = true,
showResidence = false,
onTaskClick = openApp,
trailing = {
CompleteButton(taskId = nextTask.id)
}
)
} else {
EmptyState(compact = true, onTap = openApp)
}
}
}
}
}
@Composable
private fun TaskCountItem(count: Int, label: String, color: Color) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = count.toString(),
style = TextStyle(
color = ColorProvider(color),
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = label,
style = TextStyle(
color = ColorProvider(Color(0xFF666666)),
fontSize = 10.sp
)
)
}
}
}
/**
* Action to open the main app
* Launch the main activity when the widget is tapped.
*
* Shared across all three widget sizes. Task-completion actions live
* in Stream M's [CompleteTaskAction]; this receiver handles plain
* "open app" taps.
*/
class OpenAppAction : ActionCallback {
override suspend fun onAction(
@@ -163,9 +122,7 @@ class OpenAppAction : ActionCallback {
}
}
/**
* Receiver for the small widget
*/
/** AppWidget receiver for the small widget. */
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
}