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:
@@ -1,367 +1,138 @@
|
|||||||
package com.tt.honeyDue.widget
|
package com.tt.honeyDue.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
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.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
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.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.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
|
||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.fillMaxWidth
|
import androidx.glance.layout.fillMaxWidth
|
||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
import androidx.glance.layout.padding
|
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.FontWeight
|
||||||
import androidx.glance.text.Text
|
import androidx.glance.text.Text
|
||||||
import androidx.glance.text.TextStyle
|
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)
|
* Large (4x4) widget.
|
||||||
* Size: 4x4
|
*
|
||||||
|
* 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() {
|
class HoneyDueLargeWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
override val sizeMode: SizeMode = SizeMode.Single
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
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 {
|
provideContent {
|
||||||
GlanceTheme {
|
LargeWidgetContent(tasks, stats, isPremium)
|
||||||
LargeWidgetContent()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LargeWidgetContent() {
|
private fun LargeWidgetContent(
|
||||||
val prefs = currentState<Preferences>()
|
tasks: List<WidgetTaskDto>,
|
||||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
stats: WidgetStats,
|
||||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
isPremium: Boolean
|
||||||
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
|
) {
|
||||||
val totalCount = prefs[intPreferencesKey("total_tasks_count")] ?: 0
|
val openApp = actionRunCallback<OpenAppAction>()
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFFFFF8E7)) // Cream background
|
.background(WidgetColors.BACKGROUND_PRIMARY)
|
||||||
.padding(16.dp)
|
.padding(14.dp)
|
||||||
|
.clickable(openApp)
|
||||||
) {
|
) {
|
||||||
Column(
|
if (!isPremium) {
|
||||||
modifier = GlanceModifier.fillMaxSize()
|
Column(
|
||||||
) {
|
modifier = GlanceModifier.fillMaxSize(),
|
||||||
// Header with logo
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(actionRunCallback<OpenAppAction>()),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
TaskCountBlock(count = tasks.size, long = true)
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} 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
|
if (tasks.isEmpty()) {
|
||||||
Row(
|
Box(
|
||||||
modifier = GlanceModifier.fillMaxWidth(),
|
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
contentAlignment = Alignment.Center
|
||||||
) {
|
|
||||||
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
|
|
||||||
) {
|
) {
|
||||||
Text(
|
EmptyState(compact = false, onTap = openApp)
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
val shown = tasks.take(MAX_TASKS)
|
||||||
LazyColumn(
|
shown.forEachIndexed { index, task ->
|
||||||
modifier = GlanceModifier.fillMaxSize()
|
TaskRow(
|
||||||
) {
|
|
||||||
items(tasks) { task ->
|
|
||||||
InteractiveTaskItem(
|
|
||||||
task = task,
|
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
|
companion object {
|
||||||
private fun StatBox(count: Int, label: String, color: Color, bgColor: Color) {
|
private const val MAX_TASKS = 5
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** AppWidget receiver for the large widget. */
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,252 +1,125 @@
|
|||||||
package com.tt.honeyDue.widget
|
package com.tt.honeyDue.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
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.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
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.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.lazy.LazyColumn
|
|
||||||
import androidx.glance.appwidget.lazy.items
|
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
import androidx.glance.layout.Row
|
||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxHeight
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.fillMaxWidth
|
|
||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
import androidx.glance.layout.padding
|
import androidx.glance.layout.padding
|
||||||
import androidx.glance.layout.width
|
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
|
* Medium (4x2) widget.
|
||||||
* Size: 4x2
|
*
|
||||||
|
* 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() {
|
class HoneyDueMediumWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
override val sizeMode: SizeMode = SizeMode.Single
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
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 {
|
provideContent {
|
||||||
GlanceTheme {
|
MediumWidgetContent(tasks, isPremium)
|
||||||
MediumWidgetContent()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediumWidgetContent() {
|
private fun MediumWidgetContent(
|
||||||
val prefs = currentState<Preferences>()
|
tasks: List<WidgetTaskDto>,
|
||||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
isPremium: Boolean
|
||||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
) {
|
||||||
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
|
val openApp = actionRunCallback<OpenAppAction>()
|
||||||
|
|
||||||
val tasks = try {
|
|
||||||
json.decodeFromString<List<WidgetTask>>(tasksJson).take(5)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFFFFF8E7)) // Cream background
|
.background(WidgetColors.BACKGROUND_PRIMARY)
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
|
.clickable(openApp)
|
||||||
) {
|
) {
|
||||||
Column(
|
if (!isPremium) {
|
||||||
modifier = GlanceModifier.fillMaxSize()
|
Column(
|
||||||
) {
|
modifier = GlanceModifier.fillMaxSize(),
|
||||||
// Header
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(actionRunCallback<OpenAppAction>()),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
TaskCountBlock(count = tasks.size, long = true)
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxSize(),
|
||||||
// Task list
|
verticalAlignment = Alignment.CenterVertically
|
||||||
if (tasks.isEmpty()) {
|
) {
|
||||||
|
// Left: big count
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier.width(90.dp).fillMaxHeight(),
|
||||||
.fillMaxSize()
|
|
||||||
.clickable(actionRunCallback<OpenAppAction>()),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
TaskCountBlock(count = tasks.size, long = false)
|
||||||
text = "No upcoming tasks",
|
|
||||||
style = TextStyle(
|
|
||||||
color = ColorProvider(Color(0xFF888888)),
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LazyColumn(
|
// Thin divider
|
||||||
modifier = GlanceModifier.fillMaxSize()
|
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 ->
|
if (tasks.isEmpty()) {
|
||||||
TaskListItem(task = task)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** AppWidget receiver for the medium widget. */
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,151 +3,110 @@ package com.tt.honeyDue.widget
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
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.ActionParameters
|
||||||
|
import androidx.glance.action.actionParametersOf
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
|
||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.fillMaxWidth
|
|
||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
import androidx.glance.layout.padding
|
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
|
* Small (2x2) widget.
|
||||||
* Size: 2x1 or 2x2
|
*
|
||||||
|
* 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() {
|
class HoneyDueSmallWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
override val sizeMode: SizeMode = SizeMode.Single
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
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 {
|
provideContent {
|
||||||
GlanceTheme {
|
SmallWidgetContent(tasks, isPremium)
|
||||||
SmallWidgetContent()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SmallWidgetContent() {
|
private fun SmallWidgetContent(
|
||||||
val prefs = currentState<Preferences>()
|
tasks: List<WidgetTaskDto>,
|
||||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
isPremium: Boolean
|
||||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
) {
|
||||||
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
|
val openApp = actionRunCallback<OpenAppAction>()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFFFFF8E7)) // Cream background
|
.background(WidgetColors.BACKGROUND_PRIMARY)
|
||||||
.clickable(actionRunCallback<OpenAppAction>())
|
.padding(12.dp)
|
||||||
.padding(12.dp),
|
.clickable(openApp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
if (!isPremium) {
|
||||||
modifier = GlanceModifier.fillMaxWidth(),
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
// App name/logo
|
modifier = GlanceModifier.fillMaxSize()
|
||||||
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
|
|
||||||
) {
|
) {
|
||||||
// Overdue
|
TaskCountBlock(count = tasks.size, long = true)
|
||||||
TaskCountItem(
|
}
|
||||||
count = overdueCount,
|
} else {
|
||||||
label = "Overdue",
|
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
color = Color(0xFFDD1C1A) // Red
|
TaskCountBlock(count = tasks.size, long = false)
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = GlanceModifier.width(16.dp))
|
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||||
|
|
||||||
// Due Soon
|
val nextTask = tasks.firstOrNull()
|
||||||
TaskCountItem(
|
if (nextTask != null) {
|
||||||
count = dueSoonCount,
|
TaskRow(
|
||||||
label = "Due Soon",
|
task = nextTask,
|
||||||
color = Color(0xFFF5A623) // Amber
|
compact = true,
|
||||||
)
|
showResidence = false,
|
||||||
|
onTaskClick = openApp,
|
||||||
Spacer(modifier = GlanceModifier.width(16.dp))
|
trailing = {
|
||||||
|
CompleteButton(taskId = nextTask.id)
|
||||||
// In Progress
|
}
|
||||||
TaskCountItem(
|
)
|
||||||
count = inProgressCount,
|
} else {
|
||||||
label = "Active",
|
EmptyState(compact = true, onTap = openApp)
|
||||||
color = Color(0xFF07A0C3) // Primary
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
class OpenAppAction : ActionCallback {
|
||||||
override suspend fun onAction(
|
override suspend fun onAction(
|
||||||
@@ -163,9 +122,7 @@ class OpenAppAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** AppWidget receiver for the small widget. */
|
||||||
* Receiver for the small widget
|
|
||||||
*/
|
|
||||||
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user