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
@@ -1,367 +1,138 @@
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.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.action.ActionCallback
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
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 androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Large widget showing task list with stats and interactive actions (Pro only)
* Size: 4x4
* Large (4x4) widget.
*
* Mirrors iOS `LargeWidgetView`:
* - When there are tasks: list of up to 5 tasks with residence/due
* labels, optional "+N more" text, and a 3-pill stats row at the
* bottom (Overdue / 7 Days / 30 Days).
* - When empty: centered "All caught up!" state above the stats.
* - Free tier collapses to the count-only layout.
*
* Glance restriction: no LazyColumn here because the list is bounded
* (max 5), so a plain Column is fine and lets us compose the stats row
* at the bottom without nesting a second scroll container.
*/
class HoneyDueLargeWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
private val json = Json { ignoreUnknownKeys = true }
override val sizeMode: SizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
val tasks = repo.loadTasks()
val stats = repo.computeStats()
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
provideContent {
GlanceTheme {
LargeWidgetContent()
}
LargeWidgetContent(tasks, stats, isPremium)
}
}
@Composable
private fun LargeWidgetContent() {
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
val totalCount = prefs[intPreferencesKey("total_tasks_count")] ?: 0
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
val isProUser = prefs[stringPreferencesKey("is_pro_user")] == "true"
val tasks = try {
json.decodeFromString<List<WidgetTask>>(tasksJson).take(8)
} catch (e: Exception) {
emptyList()
}
private fun LargeWidgetContent(
tasks: List<WidgetTaskDto>,
stats: WidgetStats,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color(0xFFFFF8E7)) // Cream background
.padding(16.dp)
.background(WidgetColors.BACKGROUND_PRIMARY)
.padding(14.dp)
.clickable(openApp)
) {
Column(
modifier = GlanceModifier.fillMaxSize()
) {
// Header with logo
Row(
modifier = GlanceModifier
.fillMaxWidth()
.clickable(actionRunCallback<OpenAppAction>()),
if (!isPremium) {
Column(
modifier = GlanceModifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "honeyDue",
style = TextStyle(
color = ColorProvider(Color(0xFF07A0C3)),
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = "Tasks",
style = TextStyle(
color = ColorProvider(Color(0xFF666666)),
fontSize = 14.sp
)
)
TaskCountBlock(count = tasks.size, long = true)
}
} else {
Column(modifier = GlanceModifier.fillMaxSize()) {
WidgetHeader(taskCount = tasks.size, onTap = openApp)
Spacer(modifier = GlanceModifier.height(12.dp))
Spacer(modifier = GlanceModifier.height(10.dp))
// Stats row
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
StatBox(
count = overdueCount,
label = "Overdue",
color = Color(0xFFDD1C1A),
bgColor = Color(0xFFFFEBEB)
)
Spacer(modifier = GlanceModifier.width(8.dp))
StatBox(
count = dueSoonCount,
label = "Due Soon",
color = Color(0xFFF5A623),
bgColor = Color(0xFFFFF4E0)
)
Spacer(modifier = GlanceModifier.width(8.dp))
StatBox(
count = inProgressCount,
label = "Active",
color = Color(0xFF07A0C3),
bgColor = Color(0xFFE0F4F8)
)
}
Spacer(modifier = GlanceModifier.height(12.dp))
// Divider
Box(
modifier = GlanceModifier
.fillMaxWidth()
.height(1.dp)
.background(Color(0xFFE0E0E0))
) {}
Spacer(modifier = GlanceModifier.height(8.dp))
// Task list
if (tasks.isEmpty()) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.clickable(actionRunCallback<OpenAppAction>()),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
if (tasks.isEmpty()) {
Box(
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "All caught up!",
style = TextStyle(
color = ColorProvider(Color(0xFF07A0C3)),
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
)
Text(
text = "No tasks need attention",
style = TextStyle(
color = ColorProvider(Color(0xFF888888)),
fontSize = 12.sp
)
)
EmptyState(compact = false, onTap = openApp)
}
}
} else {
LazyColumn(
modifier = GlanceModifier.fillMaxSize()
) {
items(tasks) { task ->
InteractiveTaskItem(
} else {
val shown = tasks.take(MAX_TASKS)
shown.forEachIndexed { index, task ->
TaskRow(
task = task,
isProUser = isProUser
compact = false,
showResidence = true,
onTaskClick = openApp,
trailing = { CompleteButton(taskId = task.id) }
)
if (index < shown.lastIndex) {
Spacer(modifier = GlanceModifier.height(4.dp))
}
}
if (tasks.size > MAX_TASKS) {
Spacer(modifier = GlanceModifier.height(4.dp))
Text(
text = "+ ${tasks.size - MAX_TASKS} more",
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = 10.sp,
fontWeight = FontWeight.Medium
),
modifier = GlanceModifier.fillMaxWidth()
)
}
Spacer(modifier = GlanceModifier.defaultWeight())
}
Spacer(modifier = GlanceModifier.height(10.dp))
StatsRow(stats = stats)
}
}
}
}
@Composable
private fun StatBox(count: Int, label: String, color: Color, bgColor: Color) {
Box(
modifier = GlanceModifier
.background(bgColor)
.padding(horizontal = 12.dp, vertical = 8.dp)
.cornerRadius(8.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = count.toString(),
style = TextStyle(
color = ColorProvider(color),
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = label,
style = TextStyle(
color = ColorProvider(color),
fontSize = 10.sp
)
)
}
}
}
@Composable
private fun InteractiveTaskItem(task: WidgetTask, isProUser: Boolean) {
val taskIdKey = ActionParameters.Key<Int>("task_id")
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable(
actionRunCallback<OpenTaskAction>(
actionParametersOf(taskIdKey to task.id)
)
),
verticalAlignment = Alignment.CenterVertically
) {
// Priority indicator
Box(
modifier = GlanceModifier
.width(4.dp)
.height(40.dp)
.background(getPriorityColor(task.priorityLevel))
) {}
Spacer(modifier = GlanceModifier.width(8.dp))
// Task details
Column(
modifier = GlanceModifier.defaultWeight()
) {
Text(
text = task.title,
style = TextStyle(
color = ColorProvider(Color(0xFF1A1A1A)),
fontSize = 14.sp,
fontWeight = FontWeight.Medium
),
maxLines = 1
)
Row {
Text(
text = task.residenceName,
style = TextStyle(
color = ColorProvider(Color(0xFF666666)),
fontSize = 11.sp
),
maxLines = 1
)
if (task.dueDate != null) {
Text(
text = "${task.dueDate}",
style = TextStyle(
color = ColorProvider(
if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666)
),
fontSize = 11.sp
)
)
}
}
}
// Action button (Pro only)
if (isProUser) {
Box(
modifier = GlanceModifier
.size(32.dp)
.background(Color(0xFF07A0C3))
.cornerRadius(16.dp)
.clickable(
actionRunCallback<CompleteTaskAction>(
actionParametersOf(taskIdKey to task.id)
)
),
contentAlignment = Alignment.Center
) {
Text(
text = "",
style = TextStyle(
color = ColorProvider(Color.White),
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
)
}
}
}
}
private fun getPriorityColor(level: Int): Color {
return when (level) {
4 -> Color(0xFFDD1C1A) // Urgent - Red
3 -> Color(0xFFF5A623) // High - Amber
2 -> Color(0xFF07A0C3) // Medium - Primary
else -> Color(0xFF888888) // Low - Gray
}
companion object {
private const val MAX_TASKS = 5
}
}
/**
* Action to complete a task from the widget (Pro only)
*/
class CompleteTaskAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val taskId = parameters[ActionParameters.Key<Int>("task_id")] ?: return
// Send broadcast to app to complete the task
val intent = Intent("com.tt.honeyDue.COMPLETE_TASK").apply {
putExtra("task_id", taskId)
setPackage(context.packageName)
}
context.sendBroadcast(intent)
// Update widget after action
withContext(Dispatchers.Main) {
HoneyDueLargeWidget().update(context, glanceId)
}
}
}
/**
* Receiver for the large widget
*/
/** AppWidget receiver for the large widget. */
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
}