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:
@@ -0,0 +1,368 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user