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

@@ -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<Preferences>()
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<List<WidgetTask>>(tasksJson).take(8)
} catch (e: Exception) {
emptyList()
}
private fun LargeWidgetContent(
tasks: List<WidgetTaskDto>,
stats: WidgetStats,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
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<OpenAppAction>()),
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<OpenAppAction>()),
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<Int>("task_id")
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable(
actionRunCallback<OpenTaskAction>(
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<CompleteTaskAction>(
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<Int>("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()
}

View File

@@ -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<Preferences>()
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<List<WidgetTask>>(tasksJson).take(5)
} catch (e: Exception) {
emptyList()
}
private fun MediumWidgetContent(
tasks: List<WidgetTaskDto>,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
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<OpenAppAction>()),
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<OpenAppAction>()),
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<Int>("task_id")
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(
actionRunCallback<OpenTaskAction>(
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<Int>("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()
}

View File

@@ -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<Preferences>()
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<WidgetTaskDto>,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color(0xFFFFF8E7)) // Cream background
.clickable(actionRunCallback<OpenAppAction>())
.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()
}

View File

@@ -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)
}

View File

@@ -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"
}

View File

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

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