Add haptic feedback, rich task completion, and Google Sign-In preparation
- Add platform haptic feedback abstraction (HapticFeedback.kt) with implementations for Android, iOS, JVM, JS, and WASM - Enhance CompleteTaskDialog with interactive 5-star rating, image thumbnails, and haptic feedback - Add ImageBitmap platform abstraction for displaying selected images - Localize TaskTemplatesBrowserSheet with string resources - Add Android widgets infrastructure (small, medium, large sizes) - Add Google Sign-In button components and auth flow preparation - Update strings.xml with new localization keys for completions, templates, and document features - Integrate haptic feedback into ThemePickerDialog 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package com.example.casera.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.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
|
||||
*/
|
||||
class CaseraLargeWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
LargeWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
// Header with logo
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Casera",
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(12.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
|
||||
) {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
InteractiveTaskItem(
|
||||
task = task,
|
||||
isProUser = isProUser
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.example.casera.COMPLETE_TASK").apply {
|
||||
putExtra("task_id", taskId)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
|
||||
// Update widget after action
|
||||
withContext(Dispatchers.Main) {
|
||||
CaseraLargeWidget().update(context, glanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the large widget
|
||||
*/
|
||||
class CaseraLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CaseraLargeWidget()
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.example.casera.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.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.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
|
||||
*/
|
||||
class CaseraMediumWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
MediumWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Casera",
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task list
|
||||
if (tasks.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No upcoming tasks",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF888888)),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
TaskListItem(task = task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
*/
|
||||
class CaseraMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CaseraMediumWidget()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.example.casera.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.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
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.example.casera.R
|
||||
|
||||
/**
|
||||
* Small widget showing task count summary
|
||||
* Size: 2x1 or 2x2
|
||||
*/
|
||||
class CaseraSmallWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
SmallWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.clickable(actionRunCallback<OpenAppAction>())
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// App name/logo
|
||||
Text(
|
||||
text = "Casera",
|
||||
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
|
||||
TaskCountItem(
|
||||
count = overdueCount,
|
||||
label = "Overdue",
|
||||
color = Color(0xFFDD1C1A) // Red
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(16.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
*/
|
||||
class OpenAppAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
intent?.let {
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the small widget
|
||||
*/
|
||||
class CaseraSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CaseraSmallWidget()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// DataStore instance
|
||||
private val Context.widgetDataStore: DataStore<Preferences> by preferencesDataStore(name = "widget_data")
|
||||
|
||||
/**
|
||||
* Data class representing a task for the widget
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetTask(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val residenceName: String,
|
||||
val dueDate: String?,
|
||||
val isOverdue: Boolean,
|
||||
val categoryName: String,
|
||||
val priorityLevel: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing widget summary data
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetSummary(
|
||||
val overdueCount: Int = 0,
|
||||
val dueSoonCount: Int = 0,
|
||||
val inProgressCount: Int = 0,
|
||||
val totalTasksCount: Int = 0,
|
||||
val tasks: List<WidgetTask> = emptyList(),
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Repository for managing widget data persistence
|
||||
*/
|
||||
class WidgetDataRepository(private val context: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
companion object {
|
||||
private val OVERDUE_COUNT = intPreferencesKey("overdue_count")
|
||||
private val DUE_SOON_COUNT = intPreferencesKey("due_soon_count")
|
||||
private val IN_PROGRESS_COUNT = intPreferencesKey("in_progress_count")
|
||||
private val TOTAL_TASKS_COUNT = intPreferencesKey("total_tasks_count")
|
||||
private val TASKS_JSON = stringPreferencesKey("tasks_json")
|
||||
private val LAST_UPDATED = longPreferencesKey("last_updated")
|
||||
private val IS_PRO_USER = stringPreferencesKey("is_pro_user")
|
||||
private val USER_NAME = stringPreferencesKey("user_name")
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: WidgetDataRepository? = null
|
||||
|
||||
fun getInstance(context: Context): WidgetDataRepository {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget summary as a Flow
|
||||
*/
|
||||
val widgetSummary: Flow<WidgetSummary> = context.widgetDataStore.data.map { preferences ->
|
||||
val tasksJson = preferences[TASKS_JSON] ?: "[]"
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
WidgetSummary(
|
||||
overdueCount = preferences[OVERDUE_COUNT] ?: 0,
|
||||
dueSoonCount = preferences[DUE_SOON_COUNT] ?: 0,
|
||||
inProgressCount = preferences[IN_PROGRESS_COUNT] ?: 0,
|
||||
totalTasksCount = preferences[TOTAL_TASKS_COUNT] ?: 0,
|
||||
tasks = tasks,
|
||||
lastUpdated = preferences[LAST_UPDATED] ?: 0L
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a Pro subscriber
|
||||
*/
|
||||
val isProUser: Flow<Boolean> = context.widgetDataStore.data.map { preferences ->
|
||||
preferences[IS_PRO_USER] == "true"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's display name
|
||||
*/
|
||||
val userName: Flow<String> = context.widgetDataStore.data.map { preferences ->
|
||||
preferences[USER_NAME] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the widget data
|
||||
*/
|
||||
suspend fun updateWidgetData(summary: WidgetSummary) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[OVERDUE_COUNT] = summary.overdueCount
|
||||
preferences[DUE_SOON_COUNT] = summary.dueSoonCount
|
||||
preferences[IN_PROGRESS_COUNT] = summary.inProgressCount
|
||||
preferences[TOTAL_TASKS_COUNT] = summary.totalTasksCount
|
||||
preferences[TASKS_JSON] = json.encodeToString(summary.tasks)
|
||||
preferences[LAST_UPDATED] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription status
|
||||
*/
|
||||
suspend fun updateProStatus(isPro: Boolean) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[IS_PRO_USER] = if (isPro) "true" else "false"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user name
|
||||
*/
|
||||
suspend fun updateUserName(name: String) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[USER_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
*/
|
||||
suspend fun clearData() {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.models.TaskCompletionCreateRequest
|
||||
import com.example.casera.network.APILayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for handling task actions from widgets
|
||||
*/
|
||||
class WidgetTaskActionReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
"com.example.casera.COMPLETE_TASK" -> {
|
||||
val taskId = intent.getIntExtra("task_id", -1)
|
||||
if (taskId != -1) {
|
||||
completeTask(context, taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeTask(context: Context, taskId: Int) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
val token = DataManager.authToken.value
|
||||
if (token.isNullOrEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create completion request
|
||||
val request = TaskCompletionCreateRequest(
|
||||
taskId = taskId,
|
||||
notes = "Completed from widget"
|
||||
)
|
||||
|
||||
// Complete the task via API
|
||||
val result = APILayer.createTaskCompletion(request)
|
||||
|
||||
// Update widgets after completion
|
||||
if (result is com.example.casera.network.ApiResult.Success) {
|
||||
WidgetUpdateManager.updateAllWidgets(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Manager for updating all widgets with new data
|
||||
*/
|
||||
object WidgetUpdateManager {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Update all Casera widgets with new data
|
||||
*/
|
||||
fun updateAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
val summary = repository.widgetSummary.first()
|
||||
val isProUser = repository.isProUser.first()
|
||||
|
||||
updateWidgetsWithData(context, summary, isProUser)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update widgets with the provided summary data
|
||||
*/
|
||||
suspend fun updateWidgetsWithData(
|
||||
context: Context,
|
||||
summary: WidgetSummary,
|
||||
isProUser: Boolean
|
||||
) {
|
||||
val glanceManager = GlanceAppWidgetManager(context)
|
||||
|
||||
// Update small widgets
|
||||
val smallWidgetIds = glanceManager.getGlanceIds(CaseraSmallWidget::class.java)
|
||||
smallWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
}
|
||||
}
|
||||
CaseraSmallWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update medium widgets
|
||||
val mediumWidgetIds = glanceManager.getGlanceIds(CaseraMediumWidget::class.java)
|
||||
mediumWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
}
|
||||
}
|
||||
CaseraMediumWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update large widgets
|
||||
val largeWidgetIds = glanceManager.getGlanceIds(CaseraLargeWidget::class.java)
|
||||
largeWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false"
|
||||
this[longPreferencesKey("last_updated")] = summary.lastUpdated
|
||||
}
|
||||
}
|
||||
CaseraLargeWidget().update(context, id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
*/
|
||||
fun clearAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val emptyData = WidgetSummary()
|
||||
updateWidgetsWithData(context, emptyData, false)
|
||||
|
||||
// Also clear the repository
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
repository.clearData()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user