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:
Trey t
2025-12-13 00:51:17 -06:00
parent a3e1c338d2
commit 311a30ed2d
61 changed files with 3200 additions and 290 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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