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

View File

@@ -1,252 +1,125 @@
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.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.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
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.serialization.json.Json
/**
* Medium widget showing a list of upcoming tasks
* Size: 4x2
* Medium (4x2) widget.
*
* Mirrors iOS `MediumWidgetView`: left-side big task count + vertical
* divider + right-side list of the next 2-3 tasks. Free tier collapses
* to the count-only layout.
*/
class HoneyDueMediumWidget : 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 tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
provideContent {
GlanceTheme {
MediumWidgetContent()
}
MediumWidgetContent(tasks, isPremium)
}
}
@Composable
private fun MediumWidgetContent() {
val prefs = currentState<Preferences>()
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
val tasks = try {
json.decodeFromString<List<WidgetTask>>(tasksJson).take(5)
} catch (e: Exception) {
emptyList()
}
private fun MediumWidgetContent(
tasks: List<WidgetTaskDto>,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color(0xFFFFF8E7)) // Cream background
.background(WidgetColors.BACKGROUND_PRIMARY)
.padding(12.dp)
.clickable(openApp)
) {
Column(
modifier = GlanceModifier.fillMaxSize()
) {
// Header
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 = 18.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.width(8.dp))
// Badge for overdue
if (overdueCount > 0) {
Box(
modifier = GlanceModifier
.background(Color(0xFFDD1C1A))
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "$overdueCount overdue",
style = TextStyle(
color = ColorProvider(Color.White),
fontSize = 10.sp,
fontWeight = FontWeight.Medium
)
)
}
}
TaskCountBlock(count = tasks.size, long = true)
}
Spacer(modifier = GlanceModifier.height(8.dp))
// Task list
if (tasks.isEmpty()) {
} else {
Row(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
// Left: big count
Box(
modifier = GlanceModifier
.fillMaxSize()
.clickable(actionRunCallback<OpenAppAction>()),
modifier = GlanceModifier.width(90.dp).fillMaxHeight(),
contentAlignment = Alignment.Center
) {
Text(
text = "No upcoming tasks",
style = TextStyle(
color = ColorProvider(Color(0xFF888888)),
fontSize = 14.sp
)
)
TaskCountBlock(count = tasks.size, long = false)
}
} else {
LazyColumn(
modifier = GlanceModifier.fillMaxSize()
// Thin divider
Box(
modifier = GlanceModifier
.width(1.dp)
.fillMaxHeight()
.padding(vertical = 12.dp)
.background(WidgetColors.TEXT_SECONDARY)
) {}
Spacer(modifier = GlanceModifier.width(10.dp))
// Right: task list (max 3) or empty state
Column(
modifier = GlanceModifier.defaultWeight().fillMaxHeight()
) {
items(tasks) { task ->
TaskListItem(task = task)
if (tasks.isEmpty()) {
EmptyState(compact = true, onTap = openApp)
} else {
val shown = tasks.take(3)
shown.forEachIndexed { index, task ->
TaskRow(
task = task,
compact = true,
showResidence = false,
onTaskClick = openApp,
trailing = { CompleteButton(taskId = task.id, compact = true) }
)
if (index < shown.lastIndex) {
Spacer(modifier = GlanceModifier.height(4.dp))
}
}
}
}
}
}
}
}
@Composable
private fun TaskListItem(task: WidgetTask) {
val taskIdKey = ActionParameters.Key<Int>("task_id")
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(
actionRunCallback<OpenTaskAction>(
actionParametersOf(taskIdKey to task.id)
)
),
verticalAlignment = Alignment.CenterVertically
) {
// Priority indicator
Box(
modifier = GlanceModifier
.width(4.dp)
.height(32.dp)
.background(getPriorityColor(task.priorityLevel))
) {}
Spacer(modifier = GlanceModifier.width(8.dp))
Column(
modifier = GlanceModifier.fillMaxWidth()
) {
Text(
text = task.title,
style = TextStyle(
color = ColorProvider(Color(0xFF1A1A1A)),
fontSize = 13.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
)
)
}
}
}
}
}
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
}
}
}
/**
* Action to open a specific task
*/
class OpenTaskAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val taskId = parameters[ActionParameters.Key<Int>("task_id")]
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
intent?.let {
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (taskId != null) {
it.putExtra("navigate_to_task", taskId)
}
context.startActivity(it)
}
}
}
/**
* Receiver for the medium widget
*/
/** AppWidget receiver for the medium widget. */
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
}