diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueLargeWidget.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueLargeWidget.kt index 8975748..08cac09 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueLargeWidget.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueLargeWidget.kt @@ -1,367 +1,138 @@ package com.tt.honeyDue.widget import android.content.Context -import android.content.Intent import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.GlanceId import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.action.ActionParameters -import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver -import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionRunCallback -import androidx.glance.appwidget.cornerRadius -import androidx.glance.appwidget.lazy.LazyColumn -import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.background -import androidx.glance.currentState 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.fillMaxSize 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.state.GlanceStateDefinition -import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json /** - * Large widget showing task list with stats and interactive actions (Pro only) - * Size: 4x4 + * 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 stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition - - private val json = Json { ignoreUnknownKeys = true } + 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 { - GlanceTheme { - LargeWidgetContent() - } + LargeWidgetContent(tasks, stats, isPremium) } } @Composable - private fun LargeWidgetContent() { - val prefs = currentState() - val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0 - val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0 - val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0 - val totalCount = prefs[intPreferencesKey("total_tasks_count")] ?: 0 - val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]" - val isProUser = prefs[stringPreferencesKey("is_pro_user")] == "true" - - val tasks = try { - json.decodeFromString>(tasksJson).take(8) - } catch (e: Exception) { - emptyList() - } + private fun LargeWidgetContent( + tasks: List, + stats: WidgetStats, + isPremium: Boolean + ) { + val openApp = actionRunCallback() Box( modifier = GlanceModifier .fillMaxSize() - .background(Color(0xFFFFF8E7)) // Cream background - .padding(16.dp) + .background(WidgetColors.BACKGROUND_PRIMARY) + .padding(14.dp) + .clickable(openApp) ) { - Column( - modifier = GlanceModifier.fillMaxSize() - ) { - // Header with logo - Row( - modifier = GlanceModifier - .fillMaxWidth() - .clickable(actionRunCallback()), + if (!isPremium) { + Column( + modifier = GlanceModifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "honeyDue", - style = TextStyle( - color = ColorProvider(Color(0xFF07A0C3)), - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ) - ) - - Spacer(modifier = GlanceModifier.width(8.dp)) - - Text( - text = "Tasks", - style = TextStyle( - color = ColorProvider(Color(0xFF666666)), - fontSize = 14.sp - ) - ) + TaskCountBlock(count = tasks.size, long = true) } + } else { + Column(modifier = GlanceModifier.fillMaxSize()) { + WidgetHeader(taskCount = tasks.size, onTap = openApp) - Spacer(modifier = GlanceModifier.height(12.dp)) + Spacer(modifier = GlanceModifier.height(10.dp)) - // Stats row - Row( - modifier = GlanceModifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - StatBox( - count = overdueCount, - label = "Overdue", - color = Color(0xFFDD1C1A), - bgColor = Color(0xFFFFEBEB) - ) - - Spacer(modifier = GlanceModifier.width(8.dp)) - - StatBox( - count = dueSoonCount, - label = "Due Soon", - color = Color(0xFFF5A623), - bgColor = Color(0xFFFFF4E0) - ) - - Spacer(modifier = GlanceModifier.width(8.dp)) - - StatBox( - count = inProgressCount, - label = "Active", - color = Color(0xFF07A0C3), - bgColor = Color(0xFFE0F4F8) - ) - } - - Spacer(modifier = GlanceModifier.height(12.dp)) - - // Divider - Box( - modifier = GlanceModifier - .fillMaxWidth() - .height(1.dp) - .background(Color(0xFFE0E0E0)) - ) {} - - Spacer(modifier = GlanceModifier.height(8.dp)) - - // Task list - if (tasks.isEmpty()) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .clickable(actionRunCallback()), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally + if (tasks.isEmpty()) { + Box( + modifier = GlanceModifier.defaultWeight().fillMaxWidth(), + contentAlignment = Alignment.Center ) { - Text( - text = "All caught up!", - style = TextStyle( - color = ColorProvider(Color(0xFF07A0C3)), - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - ) - Text( - text = "No tasks need attention", - style = TextStyle( - color = ColorProvider(Color(0xFF888888)), - fontSize = 12.sp - ) - ) + EmptyState(compact = false, onTap = openApp) } - } - } else { - LazyColumn( - modifier = GlanceModifier.fillMaxSize() - ) { - items(tasks) { task -> - InteractiveTaskItem( + } else { + val shown = tasks.take(MAX_TASKS) + shown.forEachIndexed { index, task -> + TaskRow( task = task, - isProUser = isProUser + 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) } } } } - @Composable - private fun StatBox(count: Int, label: String, color: Color, bgColor: Color) { - Box( - modifier = GlanceModifier - .background(bgColor) - .padding(horizontal = 12.dp, vertical = 8.dp) - .cornerRadius(8.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = count.toString(), - style = TextStyle( - color = ColorProvider(color), - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ) - ) - Text( - text = label, - style = TextStyle( - color = ColorProvider(color), - fontSize = 10.sp - ) - ) - } - } - } - - @Composable - private fun InteractiveTaskItem(task: WidgetTask, isProUser: Boolean) { - val taskIdKey = ActionParameters.Key("task_id") - - Row( - modifier = GlanceModifier - .fillMaxWidth() - .padding(vertical = 6.dp) - .clickable( - actionRunCallback( - actionParametersOf(taskIdKey to task.id) - ) - ), - verticalAlignment = Alignment.CenterVertically - ) { - // Priority indicator - Box( - modifier = GlanceModifier - .width(4.dp) - .height(40.dp) - .background(getPriorityColor(task.priorityLevel)) - ) {} - - Spacer(modifier = GlanceModifier.width(8.dp)) - - // Task details - Column( - modifier = GlanceModifier.defaultWeight() - ) { - Text( - text = task.title, - style = TextStyle( - color = ColorProvider(Color(0xFF1A1A1A)), - fontSize = 14.sp, - fontWeight = FontWeight.Medium - ), - maxLines = 1 - ) - Row { - Text( - text = task.residenceName, - style = TextStyle( - color = ColorProvider(Color(0xFF666666)), - fontSize = 11.sp - ), - maxLines = 1 - ) - if (task.dueDate != null) { - Text( - text = " • ${task.dueDate}", - style = TextStyle( - color = ColorProvider( - if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666) - ), - fontSize = 11.sp - ) - ) - } - } - } - - // Action button (Pro only) - if (isProUser) { - Box( - modifier = GlanceModifier - .size(32.dp) - .background(Color(0xFF07A0C3)) - .cornerRadius(16.dp) - .clickable( - actionRunCallback( - actionParametersOf(taskIdKey to task.id) - ) - ), - contentAlignment = Alignment.Center - ) { - Text( - text = "✓", - style = TextStyle( - color = ColorProvider(Color.White), - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - ) - } - } - } - } - - private fun getPriorityColor(level: Int): Color { - return when (level) { - 4 -> Color(0xFFDD1C1A) // Urgent - Red - 3 -> Color(0xFFF5A623) // High - Amber - 2 -> Color(0xFF07A0C3) // Medium - Primary - else -> Color(0xFF888888) // Low - Gray - } + companion object { + private const val MAX_TASKS = 5 } } -/** - * Action to complete a task from the widget (Pro only) - */ -class CompleteTaskAction : ActionCallback { - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters - ) { - val taskId = parameters[ActionParameters.Key("task_id")] ?: return - - // Send broadcast to app to complete the task - val intent = Intent("com.tt.honeyDue.COMPLETE_TASK").apply { - putExtra("task_id", taskId) - setPackage(context.packageName) - } - context.sendBroadcast(intent) - - // Update widget after action - withContext(Dispatchers.Main) { - HoneyDueLargeWidget().update(context, glanceId) - } - } -} - -/** - * Receiver for the large widget - */ +/** AppWidget receiver for the large widget. */ class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget() } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueMediumWidget.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueMediumWidget.kt index ab01a58..1ccb820 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueMediumWidget.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueMediumWidget.kt @@ -1,252 +1,125 @@ package com.tt.honeyDue.widget import android.content.Context -import android.content.Intent import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.glance.GlanceId import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.action.ActionParameters -import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver -import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionRunCallback -import androidx.glance.appwidget.lazy.LazyColumn -import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.background -import androidx.glance.currentState 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.fillMaxHeight import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.width -import androidx.glance.state.GlanceStateDefinition -import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.serialization.json.Json /** - * Medium widget showing a list of upcoming tasks - * Size: 4x2 + * Medium (4x2) widget. + * + * Mirrors iOS `MediumWidgetView`: left-side big task count + vertical + * divider + right-side list of the next 2-3 tasks. Free tier collapses + * to the count-only layout. */ class HoneyDueMediumWidget : GlanceAppWidget() { - override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition - - private val json = Json { ignoreUnknownKeys = true } + override val sizeMode: SizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = WidgetDataRepository.get(context) + val tasks = repo.loadTasks() + val tier = repo.loadTierState() + val isPremium = tier.equals("premium", ignoreCase = true) + provideContent { - GlanceTheme { - MediumWidgetContent() - } + MediumWidgetContent(tasks, isPremium) } } @Composable - private fun MediumWidgetContent() { - val prefs = currentState() - val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0 - val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0 - val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]" - - val tasks = try { - json.decodeFromString>(tasksJson).take(5) - } catch (e: Exception) { - emptyList() - } + private fun MediumWidgetContent( + tasks: List, + isPremium: Boolean + ) { + val openApp = actionRunCallback() Box( modifier = GlanceModifier .fillMaxSize() - .background(Color(0xFFFFF8E7)) // Cream background + .background(WidgetColors.BACKGROUND_PRIMARY) .padding(12.dp) + .clickable(openApp) ) { - Column( - modifier = GlanceModifier.fillMaxSize() - ) { - // Header - Row( - modifier = GlanceModifier - .fillMaxWidth() - .clickable(actionRunCallback()), + if (!isPremium) { + Column( + modifier = GlanceModifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "honeyDue", - style = TextStyle( - color = ColorProvider(Color(0xFF07A0C3)), - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - ) - - Spacer(modifier = GlanceModifier.width(8.dp)) - - // Badge for overdue - if (overdueCount > 0) { - Box( - modifier = GlanceModifier - .background(Color(0xFFDD1C1A)) - .padding(horizontal = 6.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "$overdueCount overdue", - style = TextStyle( - color = ColorProvider(Color.White), - fontSize = 10.sp, - fontWeight = FontWeight.Medium - ) - ) - } - } + TaskCountBlock(count = tasks.size, long = true) } - - Spacer(modifier = GlanceModifier.height(8.dp)) - - // Task list - if (tasks.isEmpty()) { + } else { + Row( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + // Left: big count Box( - modifier = GlanceModifier - .fillMaxSize() - .clickable(actionRunCallback()), + modifier = GlanceModifier.width(90.dp).fillMaxHeight(), contentAlignment = Alignment.Center ) { - Text( - text = "No upcoming tasks", - style = TextStyle( - color = ColorProvider(Color(0xFF888888)), - fontSize = 14.sp - ) - ) + TaskCountBlock(count = tasks.size, long = false) } - } else { - LazyColumn( - modifier = GlanceModifier.fillMaxSize() + + // Thin divider + Box( + modifier = GlanceModifier + .width(1.dp) + .fillMaxHeight() + .padding(vertical = 12.dp) + .background(WidgetColors.TEXT_SECONDARY) + ) {} + + Spacer(modifier = GlanceModifier.width(10.dp)) + + // Right: task list (max 3) or empty state + Column( + modifier = GlanceModifier.defaultWeight().fillMaxHeight() ) { - items(tasks) { task -> - TaskListItem(task = task) + if (tasks.isEmpty()) { + EmptyState(compact = true, onTap = openApp) + } else { + val shown = tasks.take(3) + shown.forEachIndexed { index, task -> + TaskRow( + task = task, + compact = true, + showResidence = false, + onTaskClick = openApp, + trailing = { CompleteButton(taskId = task.id, compact = true) } + ) + if (index < shown.lastIndex) { + Spacer(modifier = GlanceModifier.height(4.dp)) + } + } } } } } } } - - @Composable - private fun TaskListItem(task: WidgetTask) { - val taskIdKey = ActionParameters.Key("task_id") - - Row( - modifier = GlanceModifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable( - actionRunCallback( - actionParametersOf(taskIdKey to task.id) - ) - ), - verticalAlignment = Alignment.CenterVertically - ) { - // Priority indicator - Box( - modifier = GlanceModifier - .width(4.dp) - .height(32.dp) - .background(getPriorityColor(task.priorityLevel)) - ) {} - - Spacer(modifier = GlanceModifier.width(8.dp)) - - Column( - modifier = GlanceModifier.fillMaxWidth() - ) { - Text( - text = task.title, - style = TextStyle( - color = ColorProvider(Color(0xFF1A1A1A)), - fontSize = 13.sp, - fontWeight = FontWeight.Medium - ), - maxLines = 1 - ) - Row { - Text( - text = task.residenceName, - style = TextStyle( - color = ColorProvider(Color(0xFF666666)), - fontSize = 11.sp - ), - maxLines = 1 - ) - if (task.dueDate != null) { - Text( - text = " • ${task.dueDate}", - style = TextStyle( - color = ColorProvider( - if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666) - ), - fontSize = 11.sp - ) - ) - } - } - } - } - } - - private fun getPriorityColor(level: Int): Color { - return when (level) { - 4 -> Color(0xFFDD1C1A) // Urgent - Red - 3 -> Color(0xFFF5A623) // High - Amber - 2 -> Color(0xFF07A0C3) // Medium - Primary - else -> Color(0xFF888888) // Low - Gray - } - } } -/** - * Action to open a specific task - */ -class OpenTaskAction : ActionCallback { - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters - ) { - val taskId = parameters[ActionParameters.Key("task_id")] - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) - intent?.let { - it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - if (taskId != null) { - it.putExtra("navigate_to_task", taskId) - } - context.startActivity(it) - } - } -} - -/** - * Receiver for the medium widget - */ +/** AppWidget receiver for the medium widget. */ class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget() } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueSmallWidget.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueSmallWidget.kt index 0e17ce3..d38add8 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueSmallWidget.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueSmallWidget.kt @@ -3,151 +3,110 @@ package com.tt.honeyDue.widget import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.glance.GlanceId import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.Image -import androidx.glance.ImageProvider import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf 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.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.provideContent import androidx.glance.background -import androidx.glance.currentState 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.fillMaxSize -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.state.GlanceStateDefinition -import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.intPreferencesKey -import com.tt.honeyDue.R /** - * Small widget showing task count summary - * Size: 2x1 or 2x2 + * Small (2x2) widget. + * + * Mirrors iOS `SmallWidgetView` / `FreeWidgetView` in + * `iosApp/HoneyDue/HoneyDue.swift`: + * - Free tier → big count + "tasks waiting" label. + * - Premium → task count header + single next-task row with + * an inline complete button wired to [CompleteTaskAction]. + * + * Glance restriction: no radial gradients or custom shapes, so the + * "organic" glow behind the number is dropped. Cream background and + * primary/accent colors match iOS. */ class HoneyDueSmallWidget : GlanceAppWidget() { - override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + override val sizeMode: SizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = WidgetDataRepository.get(context) + val tasks = repo.loadTasks() + val tier = repo.loadTierState() + val isPremium = tier.equals("premium", ignoreCase = true) + provideContent { - GlanceTheme { - SmallWidgetContent() - } + SmallWidgetContent(tasks, isPremium) } } @Composable - private fun SmallWidgetContent() { - val prefs = currentState() - val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0 - val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0 - val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0 + private fun SmallWidgetContent( + tasks: List, + isPremium: Boolean + ) { + val openApp = actionRunCallback() Box( modifier = GlanceModifier .fillMaxSize() - .background(Color(0xFFFFF8E7)) // Cream background - .clickable(actionRunCallback()) - .padding(12.dp), + .background(WidgetColors.BACKGROUND_PRIMARY) + .padding(12.dp) + .clickable(openApp), contentAlignment = Alignment.Center ) { - Column( - modifier = GlanceModifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // App name/logo - Text( - text = "honeyDue", - style = TextStyle( - color = ColorProvider(Color(0xFF07A0C3)), - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - ) - - Spacer(modifier = GlanceModifier.height(8.dp)) - - // Task counts row - Row( - modifier = GlanceModifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + if (!isPremium) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier.fillMaxSize() ) { - // Overdue - TaskCountItem( - count = overdueCount, - label = "Overdue", - color = Color(0xFFDD1C1A) // Red - ) + TaskCountBlock(count = tasks.size, long = true) + } + } else { + Column(modifier = GlanceModifier.fillMaxSize()) { + TaskCountBlock(count = tasks.size, long = false) - Spacer(modifier = GlanceModifier.width(16.dp)) + Spacer(modifier = GlanceModifier.height(8.dp)) - // Due Soon - TaskCountItem( - count = dueSoonCount, - label = "Due Soon", - color = Color(0xFFF5A623) // Amber - ) - - Spacer(modifier = GlanceModifier.width(16.dp)) - - // In Progress - TaskCountItem( - count = inProgressCount, - label = "Active", - color = Color(0xFF07A0C3) // Primary - ) + val nextTask = tasks.firstOrNull() + if (nextTask != null) { + TaskRow( + task = nextTask, + compact = true, + showResidence = false, + onTaskClick = openApp, + trailing = { + CompleteButton(taskId = nextTask.id) + } + ) + } else { + EmptyState(compact = true, onTap = openApp) + } } } } } - - @Composable - private fun TaskCountItem(count: Int, label: String, color: Color) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = count.toString(), - style = TextStyle( - color = ColorProvider(color), - fontSize = 24.sp, - fontWeight = FontWeight.Bold - ) - ) - Text( - text = label, - style = TextStyle( - color = ColorProvider(Color(0xFF666666)), - fontSize = 10.sp - ) - ) - } - } } /** - * Action to open the main app + * Launch the main activity when the widget is tapped. + * + * Shared across all three widget sizes. Task-completion actions live + * in Stream M's [CompleteTaskAction]; this receiver handles plain + * "open app" taps. */ class OpenAppAction : ActionCallback { override suspend fun onAction( @@ -163,9 +122,7 @@ class OpenAppAction : ActionCallback { } } -/** - * Receiver for the small widget - */ +/** AppWidget receiver for the small widget. */ class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget() } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetColors.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetColors.kt new file mode 100644 index 0000000..b4c284e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetColors.kt @@ -0,0 +1,111 @@ +package com.tt.honeyDue.widget + +import androidx.compose.ui.graphics.Color +import androidx.glance.unit.ColorProvider + +/** + * Static color palette used by Glance widgets. + * + * iOS renders widgets with the in-app `Color.appPrimary`, `.appAccent`, + * `.appError`, etc. Those are dynamic per-theme on iOS and also + * dark-mode-aware; however, the **widget** process (both on iOS + * WidgetKit and Android Glance) uses a single design palette hard-coded + * to the "Teal" theme — matching the brand. Keep this module in sync + * with `iosApp/HoneyDue/HoneyDue.swift`'s `priorityColor` logic and + * `Color.appPrimary` / `Color.appAccent` / `Color.appError`. + * + * Priority "level" mapping mirrors the backend seed in + * `MyCribAPI_GO/internal/testutil/testutil.go`: + * - 1 = Low → PRIMARY + * - 2 = Medium → YELLOW_MEDIUM + * - 3 = High → ACCENT + * - 4 = Urgent → ERROR + * + * iOS uses the task's *name* string ("urgent"/"high"/"medium") to pick + * the color; we don't carry the name down in `WidgetTaskDto` so we key + * off the numeric level (which matches 1:1 with the seed IDs). + */ +object WidgetColors { + + // -- Base palette (Teal theme, light-mode values from ThemeColors.kt) -- + + /** iOS `Color.appPrimary` (Teal theme light). */ + val PRIMARY: Color = Color(0xFF07A0C3) + + /** iOS `Color.appSecondary` (Teal theme light). */ + val SECONDARY: Color = Color(0xFF0055A5) + + /** iOS `Color.appAccent` (BrightAmber). */ + val ACCENT: Color = Color(0xFFF5A623) + + /** iOS `Color.appError` (PrimaryScarlet). */ + val ERROR: Color = Color(0xFFDD1C1A) + + /** iOS inline literal "medium" yellow: `Color(red: 0.92, green: 0.70, blue: 0.03)`. */ + val YELLOW_MEDIUM: Color = Color(0xFFEBB308) + + /** iOS `Color.appBackgroundPrimary` (cream). */ + val BACKGROUND_PRIMARY: Color = Color(0xFFFFF1D0) + + /** iOS `Color.appBackgroundSecondary`. */ + val BACKGROUND_SECONDARY: Color = Color(0xFFFFFFFF) + + /** iOS `Color.appTextPrimary`. */ + val TEXT_PRIMARY: Color = Color(0xFF111111) + + /** iOS `Color.appTextSecondary`. */ + val TEXT_SECONDARY: Color = Color(0xFF666666) + + /** iOS `Color.appTextOnPrimary`. */ + val TEXT_ON_PRIMARY: Color = Color(0xFFFFFFFF) + + // -- Mapping helpers -- + + /** + * Pick a priority indicator color for a given priority level. + * Unknown levels fall through to [PRIMARY] to match iOS default. + */ + fun colorForPriority(priorityLevel: Int): Color = when (priorityLevel) { + 4 -> ERROR // Urgent + 3 -> ACCENT // High + 2 -> YELLOW_MEDIUM // Medium + 1 -> PRIMARY // Low + else -> PRIMARY // iOS default branch + } + + /** + * Overdue indicator used by the "Overdue" stat pill: + * - true → ERROR (scarlet) + * - false → TEXT_SECONDARY (muted) + * + * iOS: `entry.overdueCount > 0 ? Color.appError : Color.appTextSecondary`. + */ + fun colorForOverdue(isOverdue: Boolean): Color = + if (isOverdue) ERROR else TEXT_SECONDARY + + /** + * The left priority-bar color for a task row. Overdue tasks always get + * [ERROR] regardless of priority, matching iOS + * `OrganicTaskRowView.priorityColor`. + */ + fun taskRowColor(priorityLevel: Int, isOverdue: Boolean): Color = + if (isOverdue) ERROR else colorForPriority(priorityLevel) + + /** + * Due-date pill text color. iOS: + * `.foregroundStyle(task.isOverdue ? Color.appError : Color.appAccent)` + */ + fun dueDateTextColor(isOverdue: Boolean): Color = + if (isOverdue) ERROR else ACCENT + + // -- Glance ColorProvider convenience accessors -- + + val primary: ColorProvider get() = ColorProvider(PRIMARY) + val accent: ColorProvider get() = ColorProvider(ACCENT) + val error: ColorProvider get() = ColorProvider(ERROR) + val textPrimary: ColorProvider get() = ColorProvider(TEXT_PRIMARY) + val textSecondary: ColorProvider get() = ColorProvider(TEXT_SECONDARY) + val textOnPrimary: ColorProvider get() = ColorProvider(TEXT_ON_PRIMARY) + val backgroundPrimary: ColorProvider get() = ColorProvider(BACKGROUND_PRIMARY) + val backgroundSecondary: ColorProvider get() = ColorProvider(BACKGROUND_SECONDARY) +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetFormatter.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetFormatter.kt new file mode 100644 index 0000000..9ef243f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetFormatter.kt @@ -0,0 +1,54 @@ +package com.tt.honeyDue.widget + +import kotlin.math.abs + +/** + * Platform-agnostic helpers that format widget strings to match iOS exactly. + * + * The corresponding Swift implementation lives in + * `iosApp/HoneyDue/HoneyDue.swift` (see `formatWidgetDate(_:)` and the + * inline labels in `FreeWidgetView`/`SmallWidgetView`/`MediumWidgetView`). + * Any behavioral change here must be reflected on iOS and vice versa, + * otherwise the two platforms ship visually different widgets. + * + * ## Formatter parity contract + * + * - `formatDueDateRelative(0)` → `"Today"` + * - `formatDueDateRelative(1)` → `"in 1 day"` + * - `formatDueDateRelative(n>1)` → `"in N days"` + * - `formatDueDateRelative(-1)` → `"1 day ago"` + * - `formatDueDateRelative(-n)` → `"N days ago"` + * + * The shared `WidgetTaskDto` pre-computes `daysUntilDue` on the server + * side, so this function takes that offset directly rather than parsing + * the dueDate string client-side. + */ +object WidgetFormatter { + + /** + * Render a short relative due-date description. See the class doc for + * the parity contract. + */ + fun formatDueDateRelative(daysUntilDue: Int): String { + if (daysUntilDue == 0) return "Today" + if (daysUntilDue > 0) { + return if (daysUntilDue == 1) "in 1 day" else "in $daysUntilDue days" + } + val ago = abs(daysUntilDue) + return if (ago == 1) "1 day ago" else "$ago days ago" + } + + /** + * Long label under the count on the free-tier widget, matching iOS + * `FreeWidgetView`: "task waiting" / "tasks waiting". + */ + fun taskCountLabel(count: Int): String = + if (count == 1) "task waiting" else "tasks waiting" + + /** + * Short label used by SmallWidgetView/MediumWidgetView under the big + * number. iOS: "task" / "tasks". + */ + fun compactTaskCountLabel(count: Int): String = + if (count == 1) "task" else "tasks" +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUi.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUi.kt new file mode 100644 index 0000000..8164b68 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUi.kt @@ -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( + 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 +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetColorsTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetColorsTest.kt new file mode 100644 index 0000000..c8f251c --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetColorsTest.kt @@ -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) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetFormatterTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetFormatterTest.kt new file mode 100644 index 0000000..bff3d92 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetFormatterTest.kt @@ -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)) + } +}