Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueLargeWidget.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

139 lines
5.2 KiB
Kotlin

package com.tt.honeyDue.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
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.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
/**
* 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 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 {
LargeWidgetContent(tasks, stats, isPremium)
}
}
@Composable
private fun LargeWidgetContent(
tasks: List<WidgetTaskDto>,
stats: WidgetStats,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(WidgetColors.BACKGROUND_PRIMARY)
.padding(14.dp)
.clickable(openApp)
) {
if (!isPremium) {
Column(
modifier = GlanceModifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically
) {
TaskCountBlock(count = tasks.size, long = true)
}
} else {
Column(modifier = GlanceModifier.fillMaxSize()) {
WidgetHeader(taskCount = tasks.size, onTap = openApp)
Spacer(modifier = GlanceModifier.height(10.dp))
if (tasks.isEmpty()) {
Box(
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
contentAlignment = Alignment.Center
) {
EmptyState(compact = false, onTap = openApp)
}
} else {
val shown = tasks.take(MAX_TASKS)
shown.forEachIndexed { index, task ->
TaskRow(
task = task,
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)
}
}
}
}
companion object {
private const val MAX_TASKS = 5
}
}
/** AppWidget receiver for the large widget. */
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
}