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( 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 }