diff --git a/TODO-string-localization.md b/TODO-string-localization.md
new file mode 100644
index 0000000..b97d710
--- /dev/null
+++ b/TODO-string-localization.md
@@ -0,0 +1,146 @@
+# String Localization Migration TODO
+
+This file tracks the remaining work to migrate hardcoded strings to Compose Resources (`composeApp/src/commonMain/composeResources/values/strings.xml`).
+
+## Completed ✅
+
+### High Priority Files (Done)
+- [x] `DocumentFormScreen.kt` - 48 strings migrated
+- [x] `AddTaskDialog.kt` - 28 strings migrated
+
+## Remaining Work
+
+### Priority 1: Dialogs with Many Strings
+
+#### AddContractorDialog.kt (~25 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt`
+
+Strings to migrate:
+- Dialog title: "Add Contractor"
+- Field labels: Name *, Company, Phone, Email, Specialty, Notes, Website, Address
+- Validation errors: "Name is required"
+- Buttons: "Create", "Cancel"
+
+#### CompleteTaskDialog.kt (~22 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt`
+
+Strings to migrate:
+- Dialog title: "Complete Task"
+- Field labels: Notes, Actual Cost, Completion Date
+- Photo section: "Photos", "Camera", "Gallery", "Remove"
+- Buttons: "Complete", "Cancel"
+- Validation messages
+
+### Priority 2: Import/Share Dialogs (~14 strings)
+
+#### ContractorImportDialog.kt (~7 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt`
+
+#### ResidenceImportDialog.kt (~7 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt`
+
+### Priority 3: Task Components (~14 strings)
+
+#### TaskActionButtons.kt (~7 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt`
+
+#### TaskCard.kt (~7 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt`
+
+### Priority 4: Other Dialogs (~10 strings)
+
+#### JoinResidenceDialog.kt (~7 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt`
+
+#### ManageUsersDialog.kt (~2 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt`
+
+#### TaskTemplatesBrowserSheet.kt (~3 strings)
+Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt`
+
+### Priority 5: Smaller Components (~15 strings total)
+
+Files with 1-3 hardcoded strings each:
+- `InfoCard.kt`
+- `FeatureComparisonDialog.kt`
+- `ThemePickerDialog.kt`
+- `StandardCard.kt`
+- `CompactCard.kt`
+- `ApiResultHandler.kt`
+- `DocumentCard.kt`
+- `DocumentStates.kt`
+- `CompletionHistorySheet.kt`
+- `DocumentDetailScreen.kt`
+- `EditTaskScreen.kt`
+- `MainScreen.kt`
+- `UpgradePromptDialog.kt`
+- `VerifyEmailScreen.kt`
+- `VerifyResetCodeScreen.kt`
+- `UpgradeFeatureScreen.kt`
+- `ResidenceFormScreen.kt`
+
+## How to Migrate Strings
+
+### 1. Add import to the file:
+```kotlin
+import casera.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.stringResource
+```
+
+### 2. Add string to strings.xml:
+```xml
+Field Label
+```
+
+### 3. Replace hardcoded string:
+```kotlin
+// Before
+Text("Field Label")
+
+// After
+Text(stringResource(Res.string.component_field_label))
+```
+
+### 4. For strings with parameters:
+```xml
+%1$d items
+```
+```kotlin
+Text(stringResource(Res.string.items_count, count))
+```
+
+### 5. For strings used in onClick handlers:
+Define outside the lambda (stringResource is @Composable):
+```kotlin
+val errorMessage = stringResource(Res.string.error_message)
+
+Button(onClick = {
+ // Use errorMessage here
+ showError = errorMessage
+})
+```
+
+## Naming Convention
+
+Use this pattern for string names:
+- `{component}_{field}` - e.g., `contractor_name_label`
+- `{component}_{action}` - e.g., `contractor_create`
+- `{component}_{error}` - e.g., `contractor_name_error`
+
+Existing prefixes in strings.xml:
+- `auth_` - Authentication screens
+- `properties_` - Residence/property screens
+- `tasks_` - Task screens and components
+- `contractors_` - Contractor screens
+- `documents_` - Document screens
+- `profile_` - Profile screens
+- `common_` - Shared strings (Cancel, OK, Back, etc.)
+
+## Testing
+
+After migrating strings, run:
+```bash
+./gradlew :composeApp:compileDebugKotlinAndroid
+```
+
+Build should complete successfully with only deprecation warnings.
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index f8e6ee8..747aaf8 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -56,6 +56,18 @@ kotlin {
// PostHog Analytics
implementation("com.posthog:posthog-android:3.8.2")
+
+ // Google Sign In - Credential Manager
+ implementation("androidx.credentials:credentials:1.3.0")
+ implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
+ implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
+
+ // Jetpack Glance for Home Screen Widgets
+ implementation("androidx.glance:glance-appwidget:1.1.1")
+ implementation("androidx.glance:glance-material3:1.1.1")
+
+ // DataStore for widget data persistence
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index c1b4d44..5eac1e9 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -100,6 +100,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/auth/GoogleSignInManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/auth/GoogleSignInManager.kt
new file mode 100644
index 0000000..6a67f6d
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/auth/GoogleSignInManager.kt
@@ -0,0 +1,92 @@
+package com.example.casera.auth
+
+import android.content.Context
+import androidx.credentials.CredentialManager
+import androidx.credentials.CustomCredential
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetCredentialResponse
+import androidx.credentials.exceptions.GetCredentialException
+import com.example.casera.network.ApiConfig
+import com.google.android.libraries.identity.googleid.GetGoogleIdOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+
+/**
+ * Result of a Google Sign In attempt
+ */
+sealed class GoogleSignInResult {
+ data class Success(val idToken: String) : GoogleSignInResult()
+ data class Error(val message: String, val exception: Exception? = null) : GoogleSignInResult()
+ object Cancelled : GoogleSignInResult()
+}
+
+/**
+ * Manager for Google Sign In using Android Credential Manager
+ */
+class GoogleSignInManager(private val context: Context) {
+
+ private val credentialManager = CredentialManager.create(context)
+
+ /**
+ * Initiates Google Sign In flow and returns the ID token
+ */
+ suspend fun signIn(): GoogleSignInResult {
+ return try {
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setFilterByAuthorizedAccounts(false)
+ .setServerClientId(ApiConfig.GOOGLE_WEB_CLIENT_ID)
+ .setAutoSelectEnabled(true)
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(
+ request = request,
+ context = context
+ )
+
+ handleSignInResult(result)
+ } catch (e: GetCredentialException) {
+ when {
+ e.message?.contains("cancelled", ignoreCase = true) == true ||
+ e.message?.contains("user cancelled", ignoreCase = true) == true -> {
+ GoogleSignInResult.Cancelled
+ }
+ else -> {
+ GoogleSignInResult.Error("Sign in failed: ${e.message}", e)
+ }
+ }
+ } catch (e: Exception) {
+ GoogleSignInResult.Error("Unexpected error during sign in: ${e.message}", e)
+ }
+ }
+
+ private fun handleSignInResult(result: GetCredentialResponse): GoogleSignInResult {
+ val credential = result.credential
+
+ return when (credential) {
+ is CustomCredential -> {
+ if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
+ try {
+ val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
+ val idToken = googleIdTokenCredential.idToken
+
+ if (idToken.isNotEmpty()) {
+ GoogleSignInResult.Success(idToken)
+ } else {
+ GoogleSignInResult.Error("Empty ID token received")
+ }
+ } catch (e: Exception) {
+ GoogleSignInResult.Error("Failed to parse Google credential: ${e.message}", e)
+ }
+ } else {
+ GoogleSignInResult.Error("Unexpected credential type: ${credential.type}")
+ }
+ }
+ else -> {
+ GoogleSignInResult.Error("Unexpected credential type")
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/HapticFeedback.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/HapticFeedback.android.kt
new file mode 100644
index 0000000..2f70c76
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/HapticFeedback.android.kt
@@ -0,0 +1,105 @@
+package com.example.casera.platform
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import android.view.HapticFeedbackConstants
+import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+
+/**
+ * Android implementation of haptic feedback using system vibrator.
+ */
+class AndroidHapticFeedbackPerformer(
+ private val view: View,
+ private val vibrator: Vibrator?
+) : HapticFeedbackPerformer {
+
+ override fun perform(type: HapticFeedbackType) {
+ // First try View-based haptic feedback (works best)
+ val hapticConstant = when (type) {
+ HapticFeedbackType.Light -> HapticFeedbackConstants.KEYBOARD_TAP
+ HapticFeedbackType.Medium -> HapticFeedbackConstants.CONTEXT_CLICK
+ HapticFeedbackType.Heavy -> HapticFeedbackConstants.LONG_PRESS
+ HapticFeedbackType.Selection -> HapticFeedbackConstants.CLOCK_TICK
+ HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ HapticFeedbackConstants.CONFIRM
+ } else {
+ HapticFeedbackConstants.CONTEXT_CLICK
+ }
+ HapticFeedbackType.Warning -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ HapticFeedbackConstants.REJECT
+ } else {
+ HapticFeedbackConstants.LONG_PRESS
+ }
+ HapticFeedbackType.Error -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ HapticFeedbackConstants.REJECT
+ } else {
+ HapticFeedbackConstants.LONG_PRESS
+ }
+ }
+
+ val success = view.performHapticFeedback(hapticConstant)
+
+ // Fallback to vibrator if view-based feedback fails
+ if (!success && vibrator?.hasVibrator() == true) {
+ performVibration(type)
+ }
+ }
+
+ private fun performVibration(type: HapticFeedbackType) {
+ vibrator ?: return
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val effect = when (type) {
+ HapticFeedbackType.Light -> VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)
+ HapticFeedbackType.Medium -> VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)
+ HapticFeedbackType.Heavy -> VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
+ HapticFeedbackType.Selection -> VibrationEffect.createOneShot(5, VibrationEffect.DEFAULT_AMPLITUDE)
+ HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)
+ } else {
+ VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE)
+ }
+ HapticFeedbackType.Warning -> VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE)
+ HapticFeedbackType.Error -> VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE)
+ }
+ vibrator.vibrate(effect)
+ } else {
+ @Suppress("DEPRECATION")
+ val duration = when (type) {
+ HapticFeedbackType.Light -> 10L
+ HapticFeedbackType.Medium -> 20L
+ HapticFeedbackType.Heavy -> 50L
+ HapticFeedbackType.Selection -> 5L
+ HapticFeedbackType.Success -> 30L
+ HapticFeedbackType.Warning -> 40L
+ HapticFeedbackType.Error -> 60L
+ }
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(duration)
+ }
+ }
+}
+
+@Composable
+actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
+ val context = LocalContext.current
+ val view = LocalView.current
+
+ return remember {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
+ vibratorManager?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+ AndroidHapticFeedbackPerformer(view, vibrator)
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ImageBitmap.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ImageBitmap.android.kt
new file mode 100644
index 0000000..c987cd1
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ImageBitmap.android.kt
@@ -0,0 +1,19 @@
+package com.example.casera.platform
+
+import android.graphics.BitmapFactory
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+
+@Composable
+actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
+ return remember(imageData) {
+ try {
+ val bitmap = BitmapFactory.decodeByteArray(imageData.bytes, 0, imageData.bytes.size)
+ bitmap?.asImageBitmap()
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.android.kt
new file mode 100644
index 0000000..cfbf77b
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.android.kt
@@ -0,0 +1,101 @@
+package com.example.casera.ui.components.auth
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.casera.auth.GoogleSignInManager
+import com.example.casera.auth.GoogleSignInResult
+import kotlinx.coroutines.launch
+
+@Composable
+actual fun GoogleSignInButton(
+ onSignInStarted: () -> Unit,
+ onSignInSuccess: (idToken: String) -> Unit,
+ onSignInError: (message: String) -> Unit,
+ enabled: Boolean
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var isLoading by remember { mutableStateOf(false) }
+
+ val googleSignInManager = remember { GoogleSignInManager(context) }
+
+ OutlinedButton(
+ onClick = {
+ if (!isLoading && enabled) {
+ isLoading = true
+ onSignInStarted()
+
+ scope.launch {
+ when (val result = googleSignInManager.signIn()) {
+ is GoogleSignInResult.Success -> {
+ isLoading = false
+ onSignInSuccess(result.idToken)
+ }
+ is GoogleSignInResult.Error -> {
+ isLoading = false
+ onSignInError(result.message)
+ }
+ GoogleSignInResult.Cancelled -> {
+ isLoading = false
+ // User cancelled, no error needed
+ }
+ }
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ enabled = enabled && !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
+ colors = ButtonDefaults.outlinedButtonColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ // Google "G" logo using Material colors
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "G",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4285F4) // Google Blue
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = "Continue with Google",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraLargeWidget.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraLargeWidget.kt
new file mode 100644
index 0000000..c8397dd
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraLargeWidget.kt
@@ -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()
+ 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>(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()),
+ 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()),
+ 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("task_id")
+
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp)
+ .clickable(
+ actionRunCallback(
+ 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(
+ 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("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()
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraMediumWidget.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraMediumWidget.kt
new file mode 100644
index 0000000..21c43ad
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraMediumWidget.kt
@@ -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()
+ 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>(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()),
+ 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()),
+ 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("task_id")
+
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable(
+ actionRunCallback(
+ 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("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()
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraSmallWidget.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraSmallWidget.kt
new file mode 100644
index 0000000..815813b
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraSmallWidget.kt
@@ -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()
+ 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())
+ .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()
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetDataRepository.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetDataRepository.kt
new file mode 100644
index 0000000..8ef92fb
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetDataRepository.kt
@@ -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 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 = 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 = context.widgetDataStore.data.map { preferences ->
+ val tasksJson = preferences[TASKS_JSON] ?: "[]"
+ val tasks = try {
+ json.decodeFromString>(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 = context.widgetDataStore.data.map { preferences ->
+ preferences[IS_PRO_USER] == "true"
+ }
+
+ /**
+ * Get the user's display name
+ */
+ val userName: Flow = 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()
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetTaskActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetTaskActionReceiver.kt
new file mode 100644
index 0000000..09ef6ec
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetTaskActionReceiver.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetUpdateManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetUpdateManager.kt
new file mode 100644
index 0000000..b5e5d43
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetUpdateManager.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/res/layout/widget_large_preview.xml b/composeApp/src/androidMain/res/layout/widget_large_preview.xml
new file mode 100644
index 0000000..74cc44b
--- /dev/null
+++ b/composeApp/src/androidMain/res/layout/widget_large_preview.xml
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/layout/widget_loading.xml b/composeApp/src/androidMain/res/layout/widget_loading.xml
new file mode 100644
index 0000000..e8b59ef
--- /dev/null
+++ b/composeApp/src/androidMain/res/layout/widget_loading.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/layout/widget_medium_preview.xml b/composeApp/src/androidMain/res/layout/widget_medium_preview.xml
new file mode 100644
index 0000000..89ebb6e
--- /dev/null
+++ b/composeApp/src/androidMain/res/layout/widget_medium_preview.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/layout/widget_small_preview.xml b/composeApp/src/androidMain/res/layout/widget_small_preview.xml
new file mode 100644
index 0000000..61d7fa4
--- /dev/null
+++ b/composeApp/src/androidMain/res/layout/widget_small_preview.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml
index 037c6e6..ddbc699 100644
--- a/composeApp/src/androidMain/res/values/strings.xml
+++ b/composeApp/src/androidMain/res/values/strings.xml
@@ -1,4 +1,14 @@
Casera
casera_notifications
+
+
+ Casera Summary
+ Quick task count summary showing overdue, due soon, and active tasks
+
+ Casera Tasks
+ List of upcoming tasks with quick access to task details
+
+ Casera Dashboard
+ Full task dashboard with stats and interactive actions (Pro feature)
\ No newline at end of file
diff --git a/composeApp/src/androidMain/res/xml/casera_large_widget_info.xml b/composeApp/src/androidMain/res/xml/casera_large_widget_info.xml
new file mode 100644
index 0000000..715ac7a
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/casera_large_widget_info.xml
@@ -0,0 +1,17 @@
+
+
diff --git a/composeApp/src/androidMain/res/xml/casera_medium_widget_info.xml b/composeApp/src/androidMain/res/xml/casera_medium_widget_info.xml
new file mode 100644
index 0000000..ffd7e25
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/casera_medium_widget_info.xml
@@ -0,0 +1,17 @@
+
+
diff --git a/composeApp/src/androidMain/res/xml/casera_small_widget_info.xml b/composeApp/src/androidMain/res/xml/casera_small_widget_info.xml
new file mode 100644
index 0000000..e247d17
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/casera_small_widget_info.xml
@@ -0,0 +1,17 @@
+
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index a043cdb..c746dcc 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -72,6 +72,14 @@
Join Property
Enter Share Code
Join
+ Join Residence
+ Would you like to join this shared residence?
+ Joined Residence
+ You now have access to %1$s.
+ Join Failed
+ Joining...
+ Shared by: %1$s
+ Expires: %1$s
Pro Feature
Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance.
Property Members
@@ -163,6 +171,35 @@
2025-01-31
Update Task
Failed to Update Task
+ Failed to cancel task
+ Failed to restore task
+ Failed to mark task in progress
+ Failed to archive task
+ Failed to unarchive task
+ IN PROGRESS
+ Actions
+ Mark In Progress
+ Complete Task
+ Edit Task
+ Cancel Task
+ Restore Task
+ Archive Task
+ Unarchive Task
+ N/A
+ By: %1$s
+ Cost: $%1$s
+ View Photos (%1$d)
+ Add New Task
+ Property *
+ Property is required
+ Browse Task Templates
+ %1$d common tasks
+ Category is required
+ Interval Days (optional)
+ Override default frequency interval
+ Due date is required (format: YYYY-MM-DD)
+ Format: YYYY-MM-DD
+ Create Task
Overdue
@@ -184,6 +221,21 @@
In Progress
Task cancelled
+
+ Task Templates
+ Done
+ Search templates...
+ Clear
+ result
+ results
+ No Templates Found
+ Try a different search term
+ No Templates Available
+ Templates will appear here once loaded
+ Expand
+ Collapse
+ Add
+
Task Completions
Complete Task
@@ -193,6 +245,29 @@
Photos
Add Photo
Delete this completion record?
+ Complete Task: %1$s
+ Select Contractor (optional)
+ Choose a contractor or leave blank
+ Expand
+ None (manual entry)
+ Loading contractors...
+ Error loading contractors
+ Completed By Name (optional)
+ Enter name if not using contractor above
+ Actual Cost (optional)
+ Notes (optional)
+ Rating: %1$d out of 5
+ Add Images
+ Take Photo
+ Choose from Library
+ %1$d image(s) selected
+ Remove image
+ Complete
+ Quality Rating
+ Photos (%1$d/%2$d)
+ Camera
+ Library
+ Add photos of completed work (optional)
Contractors
@@ -259,6 +334,32 @@
Import Failed
Shared by: %1$s
+
+ Add Contractor
+ Edit Contractor
+ Basic Information
+ Name *
+ Company
+ Residence (Optional)
+ Personal (No Residence)
+ Only you will see this contractor
+ All users of %1$s will see this contractor
+ Contact Information
+ Phone
+ Email
+ Website
+ Specialties
+ Address
+ Street Address
+ City
+ State
+ ZIP Code
+ Notes
+ Private Notes
+ Mark as Favorite
+ Add
+ Save
+
Documents
Documents & Warranties
@@ -338,6 +439,51 @@
Previous
Next
+
+ Edit Warranty
+ Edit Document
+ Add Warranty
+ Add Document
+ Select Residence
+ Residence *
+ Document Type *
+ Title *
+ Item Name *
+ Model Number
+ Serial Number
+ Provider/Company *
+ Provider Contact
+ Claim Phone
+ Claim Email
+ Claim Website
+ Purchase Date (YYYY-MM-DD)
+ Warranty Start Date (YYYY-MM-DD)
+ Warranty End Date (YYYY-MM-DD) *
+ Description
+ Category
+ Select Category
+ None
+ Tags
+ tag1, tag2, tag3
+ Notes
+ Active
+ Existing Photos (%1$d)
+ New Photos (%1$d/%2$d)
+ Photos (%1$d/%2$d)
+ Camera
+ Gallery
+ Image %1$d
+ Remove image
+ Update Warranty
+ Update Document
+ Please select a residence
+ Title is required
+ Item name is required for warranties
+ Provider is required for warranties
+ 2024-01-15
+ 2025-01-15
+ Failed to load residences: %1$s
+
Profile
Edit Profile
@@ -453,6 +599,7 @@
Share
Import
Importing...
+ Try Again
Something went wrong. Please try again.
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt
index 0b2aec2..add9612 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt
@@ -81,11 +81,14 @@ fun App(
val navController = rememberNavController()
// Handle navigation from notification tap
+ // Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
+ // We just need to ensure the user is on MainRoute when a task navigation is requested
LaunchedEffect(navigateToTaskId) {
if (navigateToTaskId != null && isLoggedIn && isVerified) {
- // Navigate to tasks screen (task detail view is handled within the screen)
- navController.navigate(TasksRoute)
- onClearNavigateToTask()
+ // Ensure we're on the main screen - MainScreen will handle navigating to the tasks tab
+ navController.navigate(MainRoute) {
+ popUpTo(MainRoute) { inclusive = true }
+ }
}
}
@@ -373,6 +376,8 @@ fun App(
// Navigate to first residence or show message if no residences exist
// For now, this will be handled by the UI showing "add a property first"
},
+ navigateToTaskId = navigateToTaskId,
+ onClearNavigateToTask = onClearNavigateToTask,
onNavigateToEditResidence = { residence ->
navController.navigate(
EditResidenceRoute(
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt
index 20d7a5a..759f91c 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt
@@ -186,3 +186,23 @@ data class AppleSignInResponse(
val user: User,
@SerialName("is_new_user") val isNewUser: Boolean
)
+
+// Google Sign In Models
+
+/**
+ * Google Sign In request matching Go API
+ */
+@Serializable
+data class GoogleSignInRequest(
+ @SerialName("id_token") val idToken: String
+)
+
+/**
+ * Google Sign In response matching Go API
+ */
+@Serializable
+data class GoogleSignInResponse(
+ val token: String,
+ val user: User,
+ @SerialName("is_new_user") val isNewUser: Boolean
+)
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt
index cb7e0d1..4007c11 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt
@@ -1180,6 +1180,22 @@ object APILayer {
return result
}
+ suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult {
+ val result = authApi.googleSignIn(request)
+
+ // Update DataManager on success
+ if (result is ApiResult.Success) {
+ DataManager.setAuthToken(result.data.token)
+ DataManager.setCurrentUser(result.data.user)
+ // Initialize lookups after successful Google sign in
+ initializeLookups()
+ // Prefetch all data
+ prefetchAllData()
+ }
+
+ return result
+ }
+
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult {
val result = authApi.updateProfile(token, request)
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
index 45cffa3..0829e7c 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
@@ -45,4 +45,16 @@ object ApiConfig {
Environment.DEV -> "Dev Server (casera.treytartt.com)"
}
}
+
+ /**
+ * Google OAuth Web Client ID
+ * This is the Web application client ID from Google Cloud Console.
+ * It should match the GOOGLE_CLIENT_ID configured in the backend.
+ *
+ * To get this value:
+ * 1. Go to Google Cloud Console -> APIs & Services -> Credentials
+ * 2. Create or use an existing OAuth 2.0 Client ID of type "Web application"
+ * 3. Copy the Client ID (format: xxx.apps.googleusercontent.com)
+ */
+ const val GOOGLE_WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com"
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt
index fd36755..9e97d4b 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt
@@ -223,4 +223,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
+
+ // Google Sign In
+ suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult {
+ return try {
+ val response = client.post("$baseUrl/auth/google-sign-in/") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+
+ if (response.status.isSuccess()) {
+ ApiResult.Success(response.body())
+ } else {
+ val errorBody = try {
+ response.body