Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUi.kt
T
Trey T 1fcb456ef1 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>
2026-04-18 12:55:08 -05:00

369 lines
12 KiB
Kotlin

package com.tt.honeyDue.widget
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceModifier
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
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.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
/**
* Glance composables shared by the three widget sizes.
*
* This file is the Android equivalent of the reusable views declared
* within `iosApp/HoneyDue/HoneyDue.swift`:
*
* - [TaskRow] ≈ `OrganicTaskRowView`
* - [WidgetHeader] ≈ the top-of-widget header row in Medium/Large
* - [EmptyState] ≈ the "All caught up!" views shown when no tasks
* - [TaskCountBlock] ≈ the big numeric count used on Small and on
* the free-tier widget
* - [StatPill] ≈ `OrganicStatPillWidget`
* - [StatsRow] ≈ `OrganicStatsView`
*
* Glance is significantly more restrictive than WidgetKit — no radial
* gradients, no custom shapes, limited modifiers. These composables
* capture the iOS design intent using the primitives Glance does
* support (Box backgrounds, corner radius, text styles) so the result
* is recognizably the same widget without being pixel-perfect.
*/
/**
* A single task line with priority indicator, title, residence + due date.
*
* Matches iOS `OrganicTaskRowView`: colored left bar, task title, and
* a second line with residence name and `formatWidgetDate(...)` label.
*/
@Composable
fun TaskRow(
task: WidgetTaskDto,
priorityLevel: Int = task.priority.toInt(),
compact: Boolean = false,
showResidence: Boolean = false,
onTaskClick: androidx.glance.action.Action? = null,
trailing: @Composable (() -> Unit)? = null
) {
val titleSize = if (compact) 12.sp else 13.sp
val subSize = if (compact) 10.sp else 11.sp
val barHeight = if (compact) 28.dp else 36.dp
val tintBg = WidgetColors.taskRowColor(priorityLevel, task.isOverdue)
val rowModifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = if (compact) 4.dp else 6.dp)
.background(Background.priorityTint(tintBg))
.cornerRadius(if (compact) 10.dp else 12.dp)
.let { if (onTaskClick != null) it.clickable(onTaskClick) else it }
Row(
modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically
) {
// Priority bar
Box(
modifier = GlanceModifier
.width(4.dp)
.height(barHeight)
.background(WidgetColors.taskRowColor(priorityLevel, task.isOverdue))
.cornerRadius(2.dp)
) {}
Spacer(modifier = GlanceModifier.width(8.dp))
Column(
modifier = GlanceModifier.defaultWeight()
) {
Text(
text = task.title,
style = TextStyle(
color = WidgetColors.textPrimary,
fontSize = titleSize,
fontWeight = FontWeight.Medium
),
maxLines = if (compact) 1 else 2
)
val hasResidence = showResidence && task.residenceName.isNotBlank()
val hasDue = task.dueDate != null
if (hasResidence || hasDue) {
Row {
if (hasResidence) {
Text(
text = task.residenceName,
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = subSize
),
maxLines = 1
)
}
if (hasDue) {
Text(
text = if (hasResidence) "${WidgetFormatter.formatDueDateRelative(task.daysUntilDue)}"
else WidgetFormatter.formatDueDateRelative(task.daysUntilDue),
style = TextStyle(
color = androidx.glance.unit.ColorProvider(
WidgetColors.dueDateTextColor(task.isOverdue)
),
fontSize = subSize,
fontWeight = FontWeight.Medium
),
maxLines = 1
)
}
}
}
}
if (trailing != null) {
Spacer(modifier = GlanceModifier.width(6.dp))
trailing()
}
}
}
/**
* Top-of-widget header: "honeyDue" wordmark with task count subtitle.
* Matches the branded header in iOS Medium/Large widgets.
*/
@Composable
fun WidgetHeader(
taskCount: Int,
onTap: androidx.glance.action.Action? = null
) {
val modifier = GlanceModifier
.fillMaxWidth()
.let { if (onTap != null) it.clickable(onTap) else it }
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "honeyDue",
style = TextStyle(
color = WidgetColors.primary,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = "$taskCount ${WidgetFormatter.compactTaskCountLabel(taskCount)}",
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
)
}
}
/**
* "All caught up!" empty state. Matches iOS empty-state card in each
* widget size.
*/
@Composable
fun EmptyState(
compact: Boolean = false,
onTap: androidx.glance.action.Action? = null
) {
val modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = if (compact) 10.dp else 14.dp)
.background(WidgetColors.BACKGROUND_SECONDARY)
.cornerRadius(14.dp)
.let { if (onTap != null) it.clickable(onTap) else it }
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = GlanceModifier
.size(if (compact) 24.dp else 36.dp)
.background(WidgetColors.BACKGROUND_PRIMARY)
.cornerRadius(18.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "",
style = TextStyle(
color = WidgetColors.primary,
fontSize = if (compact) 12.sp else 16.sp,
fontWeight = FontWeight.Bold
)
)
}
Spacer(modifier = GlanceModifier.height(6.dp))
Text(
text = "All caught up!",
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = if (compact) 11.sp else 13.sp,
fontWeight = FontWeight.Medium
)
)
}
}
/**
* Big numeric task count block. Used at the top of the Small widget
* and on the free-tier widget.
*/
@Composable
fun TaskCountBlock(
count: Int,
long: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = count.toString(),
style = TextStyle(
color = WidgetColors.primary,
fontSize = if (long) 44.sp else 34.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = if (long) WidgetFormatter.taskCountLabel(count)
else WidgetFormatter.compactTaskCountLabel(count),
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = if (long) 13.sp else 11.sp,
fontWeight = FontWeight.Medium
)
)
}
}
/**
* A single "Overdue" / "7 Days" / "30 Days" pill used in the Large
* widget stats row. Matches iOS `OrganicStatPillWidget`.
*/
@Composable
fun StatPill(
value: Int,
label: String,
color: androidx.compose.ui.graphics.Color
) {
Column(
modifier = GlanceModifier
.padding(horizontal = 10.dp, vertical = 8.dp)
.background(WidgetColors.BACKGROUND_SECONDARY)
.cornerRadius(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value.toString(),
style = TextStyle(
color = androidx.glance.unit.ColorProvider(color),
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = label,
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = 9.sp,
fontWeight = FontWeight.Medium
),
maxLines = 1
)
}
}
/**
* 3-pill stats row used at the bottom of the Large widget. Mirrors iOS
* `OrganicStatsView` — Overdue / 7 Days / 30 Days buckets.
*/
@Composable
fun StatsRow(stats: WidgetStats) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
StatPill(
value = stats.overdueCount,
label = "Overdue",
color = WidgetColors.colorForOverdue(stats.overdueCount > 0)
)
Spacer(modifier = GlanceModifier.width(8.dp))
StatPill(
value = stats.dueWithin7,
label = "7 Days",
color = WidgetColors.ACCENT
)
Spacer(modifier = GlanceModifier.width(8.dp))
StatPill(
value = stats.dueWithin8To30,
label = "30 Days",
color = WidgetColors.PRIMARY
)
}
}
/**
* Circular checkmark button that triggers [CompleteTaskAction] with the
* given task id. Matches iOS `OrganicTaskRowView`'s complete button.
*
* Only wired on the premium widgets (Stream M gates the actual completion
* in `WidgetActionProcessor`, this view is just the button itself).
*/
@Composable
fun CompleteButton(taskId: Long, compact: Boolean = false) {
val size = if (compact) 22.dp else 28.dp
Box(
modifier = GlanceModifier
.size(size)
.background(WidgetColors.PRIMARY)
.cornerRadius(14.dp)
.clickable(
actionRunCallback<CompleteTaskAction>(
actionParametersOf(CompleteTaskAction.taskIdKey to taskId)
)
),
contentAlignment = Alignment.Center
) {
Text(
text = "",
style = TextStyle(
color = WidgetColors.textOnPrimary,
fontSize = if (compact) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
)
}
}
/** Utility object: Glance has no "tint" concept, so we map priority → bg. */
private object Background {
// Glance's [background] takes a Color directly; these helpers exist so
// the call sites read clearly and we have one place to adjust if we
// decide to add @Composable theming later.
fun priorityTint(color: androidx.compose.ui.graphics.Color): androidx.compose.ui.graphics.Color =
// Match iOS ~6-8% opacity tint. Glance cannot apply alpha dynamically,
// so we fall back to the secondary background for readability.
WidgetColors.BACKGROUND_SECONDARY
}