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

@@ -0,0 +1,93 @@
package com.tt.honeyDue.widget
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit test for [WidgetColors].
*
* Priority color mapping mirrors iOS `OrganicTaskRowView.priorityColor`
* in `iosApp/HoneyDue/HoneyDue.swift`:
*
* - urgent → appError
* - high → appAccent
* - medium → yellow
* - low → appPrimary
* - overdue → appError (overrides everything else)
*
* Priority "level" values match the backend seed in
* `MyCribAPI_GO/internal/testutil/testutil.go`:
* 1 = Low, 2 = Medium, 3 = High, 4 = Urgent.
*/
class WidgetColorsTest {
@Test
fun colorForPriority_urgent_is_error() {
assertEquals(WidgetColors.ERROR, WidgetColors.colorForPriority(priorityLevel = 4))
}
@Test
fun colorForPriority_high_is_accent() {
assertEquals(WidgetColors.ACCENT, WidgetColors.colorForPriority(priorityLevel = 3))
}
@Test
fun colorForPriority_medium_is_yellow() {
assertEquals(WidgetColors.YELLOW_MEDIUM, WidgetColors.colorForPriority(priorityLevel = 2))
}
@Test
fun colorForPriority_low_is_primary() {
assertEquals(WidgetColors.PRIMARY, WidgetColors.colorForPriority(priorityLevel = 1))
}
@Test
fun colorForPriority_unknown_defaults_to_primary() {
// iOS default branch falls through to appPrimary for any non-urgent/high/medium.
assertEquals(WidgetColors.PRIMARY, WidgetColors.colorForPriority(priorityLevel = 0))
assertEquals(WidgetColors.PRIMARY, WidgetColors.colorForPriority(priorityLevel = 99))
}
@Test
fun colorForOverdue_true_returns_error() {
assertEquals(WidgetColors.ERROR, WidgetColors.colorForOverdue(isOverdue = true))
}
@Test
fun colorForOverdue_false_returns_textSecondary() {
// iOS "Overdue" pill uses appTextSecondary when there's nothing overdue.
assertEquals(WidgetColors.TEXT_SECONDARY, WidgetColors.colorForOverdue(isOverdue = false))
}
@Test
fun taskRowColor_overdue_beats_priority() {
// iOS OrganicTaskRowView: `if task.isOverdue { return .appError }` first.
val c = WidgetColors.taskRowColor(priorityLevel = 1, isOverdue = true)
assertEquals(WidgetColors.ERROR, c)
}
@Test
fun taskRowColor_not_overdue_uses_priority() {
assertEquals(
WidgetColors.ACCENT,
WidgetColors.taskRowColor(priorityLevel = 3, isOverdue = false)
)
}
@Test
fun dueDateTextColor_overdue_is_error_otherwise_accent() {
// iOS: `.foregroundStyle(task.isOverdue ? Color.appError : Color.appAccent)`
assertEquals(WidgetColors.ERROR, WidgetColors.dueDateTextColor(isOverdue = true))
assertEquals(WidgetColors.ACCENT, WidgetColors.dueDateTextColor(isOverdue = false))
}
@Test
fun colors_are_stable_instances() {
// Sanity: make sure the constants aren't null/default — helps catch
// a refactor that accidentally resets them to Color.Unspecified.
assertEquals(Color(0xFF07A0C3), WidgetColors.PRIMARY)
assertEquals(Color(0xFFF5A623), WidgetColors.ACCENT)
assertEquals(Color(0xFFDD1C1A), WidgetColors.ERROR)
}
}

View File

@@ -0,0 +1,63 @@
package com.tt.honeyDue.widget
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure-JVM unit test for [WidgetFormatter.formatDueDateRelative].
*
* Mirrors the iOS `formatWidgetDate(_:)` helper in
* `iosApp/HoneyDue/HoneyDue.swift` — both are responsible for rendering
* a short human-friendly string from a `daysUntilDue` offset. Exact
* wording must match so the two platforms ship indistinguishable widgets.
*/
class WidgetFormatterTest {
@Test
fun formatDueDateRelative_today() {
assertEquals("Today", WidgetFormatter.formatDueDateRelative(daysUntilDue = 0))
}
@Test
fun formatDueDateRelative_tomorrow_is_in_1_day() {
// iOS formatter returns "in 1 day" for days==1 (it has no special
// "Tomorrow" case — only "Today" and then "in N day(s)" / "N day(s) ago").
assertEquals("in 1 day", WidgetFormatter.formatDueDateRelative(daysUntilDue = 1))
}
@Test
fun formatDueDateRelative_in_3_days() {
assertEquals("in 3 days", WidgetFormatter.formatDueDateRelative(daysUntilDue = 3))
}
@Test
fun formatDueDateRelative_in_7_days() {
assertEquals("in 7 days", WidgetFormatter.formatDueDateRelative(daysUntilDue = 7))
}
@Test
fun formatDueDateRelative_one_day_ago() {
assertEquals("1 day ago", WidgetFormatter.formatDueDateRelative(daysUntilDue = -1))
}
@Test
fun formatDueDateRelative_five_days_ago() {
assertEquals("5 days ago", WidgetFormatter.formatDueDateRelative(daysUntilDue = -5))
}
@Test
fun taskCountLabel_singular_plural() {
// iOS FreeWidgetView: "task waiting" / "tasks waiting"
assertEquals("task waiting", WidgetFormatter.taskCountLabel(1))
assertEquals("tasks waiting", WidgetFormatter.taskCountLabel(0))
assertEquals("tasks waiting", WidgetFormatter.taskCountLabel(5))
}
@Test
fun compactTaskCountLabel_singular_plural() {
// iOS Small/Medium widgets: short "task"/"tasks" under the count.
assertEquals("task", WidgetFormatter.compactTaskCountLabel(1))
assertEquals("tasks", WidgetFormatter.compactTaskCountLabel(0))
assertEquals("tasks", WidgetFormatter.compactTaskCountLabel(3))
}
}