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,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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user