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

146
TODO-string-localization.md Normal file
View File

@@ -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
<string name="component_field_label">Field Label</string>
```
### 3. Replace hardcoded string:
```kotlin
// Before
Text("Field Label")
// After
Text(stringResource(Res.string.component_field_label))
```
### 4. For strings with parameters:
```xml
<string name="items_count">%1$d items</string>
```
```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.

View File

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

View File

@@ -100,6 +100,54 @@
<action android:name="com.example.casera.ACTION_UNCANCEL_TASK" />
</intent-filter>
</receiver>
<!-- Widget Task Complete Receiver -->
<receiver
android:name=".widget.WidgetTaskActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.example.casera.COMPLETE_TASK" />
</intent-filter>
</receiver>
<!-- Small Widget Receiver (2x1) -->
<receiver
android:name=".widget.CaseraSmallWidgetReceiver"
android:exported="true"
android:label="@string/widget_small_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/casera_small_widget_info" />
</receiver>
<!-- Medium Widget Receiver (4x2) -->
<receiver
android:name=".widget.CaseraMediumWidgetReceiver"
android:exported="true"
android:label="@string/widget_medium_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/casera_medium_widget_info" />
</receiver>
<!-- Large Widget Receiver (4x4) -->
<receiver
android:name=".widget.CaseraLargeWidgetReceiver"
android:exported="true"
android:label="@string/widget_large_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/casera_large_widget_info" />
</receiver>
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFF8E7"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Casera"
android:textColor="#07A0C3"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Tasks"
android:textColor="#666666"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal"
android:gravity="center">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#FFEBEB"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2"
android:textColor="#DD1C1A"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Overdue"
android:textColor="#DD1C1A"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="#FFF4E0"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="3"
android:textColor="#F5A623"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Due Soon"
android:textColor="#F5A623"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="#E0F4F8"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textColor="#07A0C3"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Active"
android:textColor="#07A0C3"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="#E0E0E0" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Change HVAC filter"
android:textColor="#1A1A1A"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="My Home • Due today"
android:textColor="#DD1C1A"
android:textSize="11sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Clean gutters"
android:textColor="#1A1A1A"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="My Home • Tomorrow"
android:textColor="#666666"
android:textSize="11sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Test smoke detectors"
android:textColor="#1A1A1A"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Beach House • In 3 days"
android:textColor="#666666"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFF8E7"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Loading..."
android:textColor="#666666"
android:textSize="14sp" />
</FrameLayout>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFF8E7"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Casera"
android:textColor="#07A0C3"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="#DD1C1A"
android:paddingHorizontal="6dp"
android:paddingVertical="2dp"
android:text="2 overdue"
android:textColor="#FFFFFF"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Change HVAC filter"
android:textColor="#1A1A1A"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="My Home • Due today"
android:textColor="#666666"
android:textSize="11sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Clean gutters"
android:textColor="#1A1A1A"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="My Home • Tomorrow"
android:textColor="#666666"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFF8E7"
android:gravity="center"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Casera"
android:textColor="#07A0C3"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2"
android:textColor="#DD1C1A"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Overdue"
android:textColor="#666666"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="3"
android:textColor="#F5A623"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Due Soon"
android:textColor="#666666"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textColor="#07A0C3"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Active"
android:textColor="#666666"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -1,4 +1,14 @@
<resources>
<string name="app_name">Casera</string>
<string name="default_notification_channel_id">casera_notifications</string>
<!-- Widget Strings -->
<string name="widget_small_name">Casera Summary</string>
<string name="widget_small_description">Quick task count summary showing overdue, due soon, and active tasks</string>
<string name="widget_medium_name">Casera Tasks</string>
<string name="widget_medium_description">List of upcoming tasks with quick access to task details</string>
<string name="widget_large_name">Casera Dashboard</string>
<string name="widget_large_description">Full task dashboard with stats and interactive actions (Pro feature)</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:minResizeWidth="180dp"
android:minResizeHeight="180dp"
android:maxResizeWidth="400dp"
android:maxResizeHeight="400dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_loading"
android:previewLayout="@layout/widget_large_preview"
android:description="@string/widget_large_description"
android:updatePeriodMillis="1800000"
android:widgetFeatures="reconfigurable" />

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:minResizeWidth="180dp"
android:minResizeHeight="80dp"
android:maxResizeWidth="400dp"
android:maxResizeHeight="200dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_loading"
android:previewLayout="@layout/widget_medium_preview"
android:description="@string/widget_medium_description"
android:updatePeriodMillis="1800000"
android:widgetFeatures="reconfigurable" />

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:maxResizeWidth="300dp"
android:maxResizeHeight="120dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_loading"
android:previewLayout="@layout/widget_small_preview"
android:description="@string/widget_small_description"
android:updatePeriodMillis="1800000"
android:widgetFeatures="reconfigurable" />

View File

@@ -72,6 +72,14 @@
<string name="properties_join_title">Join Property</string>
<string name="properties_join_code_label">Enter Share Code</string>
<string name="properties_join_button">Join</string>
<string name="properties_join_residence_title">Join Residence</string>
<string name="properties_join_residence_message">Would you like to join this shared residence?</string>
<string name="properties_join_success">Joined Residence</string>
<string name="properties_join_success_message">You now have access to %1$s.</string>
<string name="properties_join_failed">Join Failed</string>
<string name="properties_joining">Joining...</string>
<string name="properties_shared_by">Shared by: %1$s</string>
<string name="properties_expires">Expires: %1$s</string>
<string name="properties_share_upgrade_title">Pro Feature</string>
<string name="properties_share_upgrade_message">Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance.</string>
<string name="properties_users_title">Property Members</string>
@@ -163,6 +171,35 @@
<string name="tasks_due_date_placeholder">2025-01-31</string>
<string name="tasks_update">Update Task</string>
<string name="tasks_failed_to_update">Failed to Update Task</string>
<string name="tasks_failed_to_cancel">Failed to cancel task</string>
<string name="tasks_failed_to_restore">Failed to restore task</string>
<string name="tasks_failed_to_mark_in_progress">Failed to mark task in progress</string>
<string name="tasks_failed_to_archive">Failed to archive task</string>
<string name="tasks_failed_to_unarchive">Failed to unarchive task</string>
<string name="tasks_card_in_progress">IN PROGRESS</string>
<string name="tasks_card_actions">Actions</string>
<string name="tasks_card_mark_in_progress">Mark In Progress</string>
<string name="tasks_card_complete_task">Complete Task</string>
<string name="tasks_card_edit_task">Edit Task</string>
<string name="tasks_card_cancel_task">Cancel Task</string>
<string name="tasks_card_restore_task">Restore Task</string>
<string name="tasks_card_archive_task">Archive Task</string>
<string name="tasks_card_unarchive_task">Unarchive Task</string>
<string name="tasks_card_not_available">N/A</string>
<string name="tasks_card_completed_by">By: %1$s</string>
<string name="tasks_card_cost">Cost: $%1$s</string>
<string name="tasks_card_view_photos">View Photos (%1$d)</string>
<string name="tasks_add_new">Add New Task</string>
<string name="tasks_property_required">Property *</string>
<string name="tasks_property_error">Property is required</string>
<string name="tasks_browse_templates">Browse Task Templates</string>
<string name="tasks_common_tasks">%1$d common tasks</string>
<string name="tasks_category_error">Category is required</string>
<string name="tasks_interval_days">Interval Days (optional)</string>
<string name="tasks_interval_override">Override default frequency interval</string>
<string name="tasks_due_date_format_error">Due date is required (format: YYYY-MM-DD)</string>
<string name="tasks_due_date_format">Format: YYYY-MM-DD</string>
<string name="tasks_create">Create Task</string>
<!-- Task Columns / Kanban -->
<string name="tasks_column_overdue">Overdue</string>
@@ -184,6 +221,21 @@
<string name="tasks_in_progress_label">In Progress</string>
<string name="tasks_cancelled_message">Task cancelled</string>
<!-- Task Templates -->
<string name="templates_title">Task Templates</string>
<string name="templates_done">Done</string>
<string name="templates_search_placeholder">Search templates...</string>
<string name="templates_clear">Clear</string>
<string name="templates_result">result</string>
<string name="templates_results">results</string>
<string name="templates_no_results_title">No Templates Found</string>
<string name="templates_no_results_message">Try a different search term</string>
<string name="templates_empty_title">No Templates Available</string>
<string name="templates_empty_message">Templates will appear here once loaded</string>
<string name="templates_expand">Expand</string>
<string name="templates_collapse">Collapse</string>
<string name="templates_add">Add</string>
<!-- Task Completions -->
<string name="completions_title">Task Completions</string>
<string name="completions_add_title">Complete Task</string>
@@ -193,6 +245,29 @@
<string name="completions_photos_label">Photos</string>
<string name="completions_add_photo">Add Photo</string>
<string name="completions_delete_confirm">Delete this completion record?</string>
<string name="completions_complete_task_title">Complete Task: %1$s</string>
<string name="completions_select_contractor">Select Contractor (optional)</string>
<string name="completions_choose_contractor_placeholder">Choose a contractor or leave blank</string>
<string name="completions_expand">Expand</string>
<string name="completions_none_manual">None (manual entry)</string>
<string name="completions_loading_contractors">Loading contractors...</string>
<string name="completions_error_loading_contractors">Error loading contractors</string>
<string name="completions_completed_by_name">Completed By Name (optional)</string>
<string name="completions_completed_by_placeholder">Enter name if not using contractor above</string>
<string name="completions_actual_cost_optional">Actual Cost (optional)</string>
<string name="completions_notes_optional">Notes (optional)</string>
<string name="completions_rating">Rating: %1$d out of 5</string>
<string name="completions_add_images">Add Images</string>
<string name="completions_take_photo">Take Photo</string>
<string name="completions_choose_from_library">Choose from Library</string>
<string name="completions_images_selected">%1$d image(s) selected</string>
<string name="completions_remove_image">Remove image</string>
<string name="completions_complete_button">Complete</string>
<string name="completions_quality_rating">Quality Rating</string>
<string name="completions_photos_count">Photos (%1$d/%2$d)</string>
<string name="completions_camera">Camera</string>
<string name="completions_library">Library</string>
<string name="completions_add_photos_helper">Add photos of completed work (optional)</string>
<!-- Contractors -->
<string name="contractors_title">Contractors</string>
@@ -259,6 +334,32 @@
<string name="contractors_import_failed">Import Failed</string>
<string name="contractors_shared_by">Shared by: %1$s</string>
<!-- Contractor Form/Dialog -->
<string name="contractors_form_add_title">Add Contractor</string>
<string name="contractors_form_edit_title">Edit Contractor</string>
<string name="contractors_form_basic_info">Basic Information</string>
<string name="contractors_form_name_required">Name *</string>
<string name="contractors_form_company">Company</string>
<string name="contractors_form_residence_optional">Residence (Optional)</string>
<string name="contractors_form_personal_no_residence">Personal (No Residence)</string>
<string name="contractors_form_personal_visibility">Only you will see this contractor</string>
<string name="contractors_form_shared_visibility">All users of %1$s will see this contractor</string>
<string name="contractors_form_contact_info">Contact Information</string>
<string name="contractors_form_phone">Phone</string>
<string name="contractors_form_email">Email</string>
<string name="contractors_form_website">Website</string>
<string name="contractors_form_specialties">Specialties</string>
<string name="contractors_form_address_section">Address</string>
<string name="contractors_form_street_address">Street Address</string>
<string name="contractors_form_city">City</string>
<string name="contractors_form_state">State</string>
<string name="contractors_form_zip_code">ZIP Code</string>
<string name="contractors_form_notes_section">Notes</string>
<string name="contractors_form_private_notes">Private Notes</string>
<string name="contractors_form_mark_favorite">Mark as Favorite</string>
<string name="contractors_form_add_button">Add</string>
<string name="contractors_form_save_button">Save</string>
<!-- Documents -->
<string name="documents_title">Documents</string>
<string name="documents_and_warranties">Documents &amp; Warranties</string>
@@ -338,6 +439,51 @@
<string name="documents_previous">Previous</string>
<string name="documents_next">Next</string>
<!-- Document Form -->
<string name="documents_form_edit_warranty">Edit Warranty</string>
<string name="documents_form_edit_document">Edit Document</string>
<string name="documents_form_add_warranty">Add Warranty</string>
<string name="documents_form_add_document">Add Document</string>
<string name="documents_form_select_residence">Select Residence</string>
<string name="documents_form_residence_required">Residence *</string>
<string name="documents_form_document_type_required">Document Type *</string>
<string name="documents_form_title_required">Title *</string>
<string name="documents_form_item_name_required">Item Name *</string>
<string name="documents_form_model_number">Model Number</string>
<string name="documents_form_serial_number">Serial Number</string>
<string name="documents_form_provider_required">Provider/Company *</string>
<string name="documents_form_provider_contact">Provider Contact</string>
<string name="documents_form_claim_phone">Claim Phone</string>
<string name="documents_form_claim_email">Claim Email</string>
<string name="documents_form_claim_website">Claim Website</string>
<string name="documents_form_purchase_date">Purchase Date (YYYY-MM-DD)</string>
<string name="documents_form_warranty_start">Warranty Start Date (YYYY-MM-DD)</string>
<string name="documents_form_warranty_end_required">Warranty End Date (YYYY-MM-DD) *</string>
<string name="documents_form_description">Description</string>
<string name="documents_form_category">Category</string>
<string name="documents_form_select_category">Select Category</string>
<string name="documents_form_category_none">None</string>
<string name="documents_form_tags">Tags</string>
<string name="documents_form_tags_placeholder">tag1, tag2, tag3</string>
<string name="documents_form_notes">Notes</string>
<string name="documents_form_active">Active</string>
<string name="documents_form_existing_photos">Existing Photos (%1$d)</string>
<string name="documents_form_new_photos">New Photos (%1$d/%2$d)</string>
<string name="documents_form_photos">Photos (%1$d/%2$d)</string>
<string name="documents_form_camera">Camera</string>
<string name="documents_form_gallery">Gallery</string>
<string name="documents_form_image_number">Image %1$d</string>
<string name="documents_form_remove_image">Remove image</string>
<string name="documents_form_update_warranty">Update Warranty</string>
<string name="documents_form_update_document">Update Document</string>
<string name="documents_form_select_residence_error">Please select a residence</string>
<string name="documents_form_title_error">Title is required</string>
<string name="documents_form_item_name_error">Item name is required for warranties</string>
<string name="documents_form_provider_error">Provider is required for warranties</string>
<string name="documents_form_date_placeholder">2024-01-15</string>
<string name="documents_form_date_placeholder_end">2025-01-15</string>
<string name="documents_form_failed_to_load_residences">Failed to load residences: %1$s</string>
<!-- Profile -->
<string name="profile_title">Profile</string>
<string name="profile_edit_title">Edit Profile</string>
@@ -453,6 +599,7 @@
<string name="common_share">Share</string>
<string name="common_import">Import</string>
<string name="common_importing">Importing...</string>
<string name="common_try_again">Try Again</string>
<!-- Errors -->
<string name="error_generic">Something went wrong. Please try again.</string>

View File

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

View File

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

View File

@@ -1180,6 +1180,22 @@ object APILayer {
return result
}
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
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<User> {
val result = authApi.updateProfile(token, request)

View File

@@ -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"
}

View File

@@ -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<GoogleSignInResponse> {
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<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Google Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,36 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
/**
* Types of haptic feedback available.
*/
enum class HapticFeedbackType {
/** Light feedback - for selections, toggles */
Light,
/** Medium feedback - for confirmations */
Medium,
/** Heavy feedback - for important actions */
Heavy,
/** Selection changed feedback */
Selection,
/** Success feedback */
Success,
/** Warning feedback */
Warning,
/** Error feedback */
Error
}
/**
* Interface for performing haptic feedback.
*/
interface HapticFeedbackPerformer {
fun perform(type: HapticFeedbackType)
}
/**
* Remember a haptic feedback performer for the current platform.
*/
@Composable
expect fun rememberHapticFeedback(): HapticFeedbackPerformer

View File

@@ -0,0 +1,11 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
/**
* Converts ImageData bytes to an ImageBitmap for display.
* Returns null if conversion fails.
*/
@Composable
expect fun rememberImageBitmap(imageData: ImageData): ImageBitmap?

View File

@@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import casera.composeapp.generated.resources.*
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.models.ContractorCreateRequest
@@ -22,6 +23,7 @@ import com.example.casera.network.ApiResult
import com.example.casera.repository.LookupsRepository
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -109,12 +111,19 @@ fun AddContractorDialog(
}
}
val dialogTitle = if (contractorId == null)
stringResource(Res.string.contractors_form_add_title)
else
stringResource(Res.string.contractors_form_edit_title)
val personalNoResidence = stringResource(Res.string.contractors_form_personal_no_residence)
val cancelText = stringResource(Res.string.common_cancel)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.fillMaxWidth(0.95f),
title = {
Text(
if (contractorId == null) "Add Contractor" else "Edit Contractor",
dialogTitle,
fontWeight = FontWeight.Bold
)
},
@@ -128,7 +137,7 @@ fun AddContractorDialog(
) {
// Basic Information Section
Text(
"Basic Information",
stringResource(Res.string.contractors_form_basic_info),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
@@ -137,7 +146,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name *") },
label = { Text(stringResource(Res.string.contractors_form_name_required)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -151,7 +160,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = company,
onValueChange = { company = it },
label = { Text("Company") },
label = { Text(stringResource(Res.string.contractors_form_company)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -168,10 +177,10 @@ fun AddContractorDialog(
onExpandedChange = { expandedResidenceMenu = it }
) {
OutlinedTextField(
value = selectedResidence?.name ?: "Personal (No Residence)",
value = selectedResidence?.name ?: personalNoResidence,
onValueChange = {},
readOnly = true,
label = { Text("Residence (Optional)") },
label = { Text(stringResource(Res.string.contractors_form_residence_optional)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
@@ -190,7 +199,7 @@ fun AddContractorDialog(
) {
// Option for no residence (personal contractor)
DropdownMenuItem(
text = { Text("Personal (No Residence)") },
text = { Text(personalNoResidence) },
onClick = {
selectedResidence = null
expandedResidenceMenu = false
@@ -214,8 +223,8 @@ fun AddContractorDialog(
}
Text(
if (selectedResidence == null) "Only you will see this contractor"
else "All users of ${selectedResidence?.name} will see this contractor",
if (selectedResidence == null) stringResource(Res.string.contractors_form_personal_visibility)
else stringResource(Res.string.contractors_form_shared_visibility, selectedResidence?.name ?: ""),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280)
)
@@ -224,7 +233,7 @@ fun AddContractorDialog(
// Contact Information Section
Text(
"Contact Information",
stringResource(Res.string.contractors_form_contact_info),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
@@ -233,7 +242,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Phone") },
label = { Text(stringResource(Res.string.contractors_form_phone)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -247,7 +256,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
label = { Text(stringResource(Res.string.contractors_form_email)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -261,7 +270,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = website,
onValueChange = { website = it },
label = { Text("Website") },
label = { Text(stringResource(Res.string.contractors_form_website)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -276,7 +285,7 @@ fun AddContractorDialog(
// Specialties Section
Text(
"Specialties",
stringResource(Res.string.contractors_form_specialties),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
@@ -310,7 +319,7 @@ fun AddContractorDialog(
// Address Section
Text(
"Address",
stringResource(Res.string.contractors_form_address_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
@@ -319,7 +328,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = streetAddress,
onValueChange = { streetAddress = it },
label = { Text("Street Address") },
label = { Text(stringResource(Res.string.contractors_form_street_address)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -334,7 +343,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text("City") },
label = { Text(stringResource(Res.string.contractors_form_city)) },
modifier = Modifier.weight(1f),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -347,7 +356,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = stateProvince,
onValueChange = { stateProvince = it },
label = { Text("State") },
label = { Text(stringResource(Res.string.contractors_form_state)) },
modifier = Modifier.weight(0.5f),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -361,7 +370,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = postalCode,
onValueChange = { postalCode = it },
label = { Text("ZIP Code") },
label = { Text(stringResource(Res.string.contractors_form_zip_code)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
@@ -375,7 +384,7 @@ fun AddContractorDialog(
// Notes Section
Text(
"Notes",
stringResource(Res.string.contractors_form_notes_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
@@ -384,7 +393,7 @@ fun AddContractorDialog(
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Private Notes") },
label = { Text(stringResource(Res.string.contractors_form_private_notes)) },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
@@ -409,7 +418,7 @@ fun AddContractorDialog(
tint = if (isFavorite) Color(0xFFF59E0B) else Color(0xFF9CA3AF)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Mark as Favorite", color = Color(0xFF111827))
Text(stringResource(Res.string.contractors_form_mark_favorite), color = Color(0xFF111827))
}
Switch(
checked = isFavorite,
@@ -491,13 +500,13 @@ fun AddContractorDialog(
strokeWidth = 2.dp
)
} else {
Text(if (contractorId == null) "Add" else "Save")
Text(if (contractorId == null) stringResource(Res.string.contractors_form_add_button) else stringResource(Res.string.contractors_form_save_button))
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color(0xFF6B7280))
Text(cancelText, color = Color(0xFF6B7280))
}
},
containerColor = Color.White,

View File

@@ -24,6 +24,8 @@ import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskPriority
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -132,7 +134,7 @@ fun AddTaskDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Task") },
title = { Text(stringResource(Res.string.tasks_add_new)) },
text = {
Column(
modifier = Modifier
@@ -149,13 +151,13 @@ fun AddTaskDialog(
OutlinedTextField(
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
onValueChange = { },
label = { Text("Property *") },
label = { Text(stringResource(Res.string.tasks_property_required)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = residenceError,
supportingText = if (residenceError) {
{ Text("Property is required") }
{ Text(stringResource(Res.string.tasks_property_error)) }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
readOnly = true,
@@ -202,11 +204,11 @@ fun AddTaskDialog(
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Browse Task Templates",
text = stringResource(Res.string.tasks_browse_templates),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${allTemplates.size} common tasks",
text = stringResource(Res.string.tasks_common_tasks, allTemplates.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -230,11 +232,11 @@ fun AddTaskDialog(
titleError = false
showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty()
},
label = { Text("Title *") },
label = { Text(stringResource(Res.string.tasks_title_required)) },
modifier = Modifier.fillMaxWidth(),
isError = titleError,
supportingText = if (titleError) {
{ Text("Title is required") }
{ Text(stringResource(Res.string.tasks_title_error)) }
} else null,
singleLine = true
)
@@ -255,7 +257,7 @@ fun AddTaskDialog(
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
label = { Text(stringResource(Res.string.tasks_description_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
@@ -269,13 +271,13 @@ fun AddTaskDialog(
OutlinedTextField(
value = categories.find { it == category }?.name ?: "",
onValueChange = { },
label = { Text("Category *") },
label = { Text(stringResource(Res.string.tasks_category_required)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = categoryError,
supportingText = if (categoryError) {
{ Text("Category is required") }
{ Text(stringResource(Res.string.tasks_category_error)) }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
readOnly = false,
@@ -306,7 +308,7 @@ fun AddTaskDialog(
OutlinedTextField(
value = frequencies.find { it == frequency }?.displayName ?: "",
onValueChange = { },
label = { Text("Frequency") },
label = { Text(stringResource(Res.string.tasks_frequency_label)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
@@ -339,10 +341,10 @@ fun AddTaskDialog(
OutlinedTextField(
value = intervalDays,
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
label = { Text("Interval Days (optional)") },
label = { Text(stringResource(Res.string.tasks_interval_days)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text("Override default frequency interval") },
supportingText = { Text(stringResource(Res.string.tasks_interval_override)) },
singleLine = true
)
}
@@ -354,13 +356,13 @@ fun AddTaskDialog(
dueDate = it
dueDateError = false
},
label = { Text("Due Date (YYYY-MM-DD) *") },
label = { Text(stringResource(Res.string.tasks_due_date_required)) },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError,
supportingText = if (dueDateError) {
{ Text("Due date is required (format: YYYY-MM-DD)") }
{ Text(stringResource(Res.string.tasks_due_date_format_error)) }
} else {
{ Text("Format: YYYY-MM-DD") }
{ Text(stringResource(Res.string.tasks_due_date_format)) }
},
singleLine = true
)
@@ -373,7 +375,7 @@ fun AddTaskDialog(
OutlinedTextField(
value = priorities.find { it.name == priority.name }?.displayName ?: "",
onValueChange = { },
label = { Text("Priority") },
label = { Text(stringResource(Res.string.tasks_priority_label)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
@@ -401,7 +403,7 @@ fun AddTaskDialog(
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
label = { Text(stringResource(Res.string.tasks_estimated_cost_label)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") },
@@ -465,13 +467,13 @@ fun AddTaskDialog(
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Create Task")
Text(stringResource(Res.string.tasks_create))
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(Res.string.common_cancel))
}
}
)

View File

@@ -1,26 +1,48 @@
package com.example.casera.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarOutline
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import casera.composeapp.generated.resources.*
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.network.ApiResult
import com.example.casera.platform.ImageData
import com.example.casera.platform.rememberImagePicker
import com.example.casera.platform.rememberCameraPicker
import com.example.casera.platform.HapticFeedbackType
import com.example.casera.platform.rememberHapticFeedback
import com.example.casera.platform.rememberImageBitmap
import kotlinx.datetime.*
import org.jetbrains.compose.resources.stringResource
private const val MAX_IMAGES = 5
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -41,6 +63,7 @@ fun CompleteTaskDialog(
var showContractorDropdown by remember { mutableStateOf(false) }
val contractorsState by contractorViewModel.contractorsState.collectAsState()
val hapticFeedback = rememberHapticFeedback()
// Load contractors when dialog opens
LaunchedEffect(Unit) {
@@ -48,16 +71,24 @@ fun CompleteTaskDialog(
}
val imagePicker = rememberImagePicker { images ->
selectedImages = images
// Add new images up to the max limit
val newTotal = (selectedImages + images).take(MAX_IMAGES)
selectedImages = newTotal
}
val cameraPicker = rememberCameraPicker { image ->
selectedImages = selectedImages + image
if (selectedImages.size < MAX_IMAGES) {
selectedImages = selectedImages + image
}
}
val noneManualEntry = stringResource(Res.string.completions_none_manual)
val cancelText = stringResource(Res.string.common_cancel)
val removeImageDesc = stringResource(Res.string.completions_remove_image)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Complete Task: $taskTitle") },
title = { Text(stringResource(Res.string.completions_complete_task_title, taskTitle)) },
text = {
Column(
modifier = Modifier
@@ -74,10 +105,10 @@ fun CompleteTaskDialog(
value = selectedContractorName ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Select Contractor (optional)") },
placeholder = { Text("Choose a contractor or leave blank") },
label = { Text(stringResource(Res.string.completions_select_contractor)) },
placeholder = { Text(stringResource(Res.string.completions_choose_contractor_placeholder)) },
trailingIcon = {
Icon(Icons.Default.ArrowDropDown, "Expand")
Icon(Icons.Default.ArrowDropDown, stringResource(Res.string.completions_expand))
},
modifier = Modifier
.fillMaxWidth()
@@ -91,7 +122,7 @@ fun CompleteTaskDialog(
) {
// "None" option to clear selection
DropdownMenuItem(
text = { Text("None (manual entry)") },
text = { Text(noneManualEntry) },
onClick = {
selectedContractorId = null
selectedContractorName = null
@@ -130,14 +161,14 @@ fun CompleteTaskDialog(
}
is ApiResult.Loading -> {
DropdownMenuItem(
text = { Text("Loading contractors...") },
text = { Text(stringResource(Res.string.completions_loading_contractors)) },
onClick = {},
enabled = false
)
}
is ApiResult.Error -> {
DropdownMenuItem(
text = { Text("Error loading contractors") },
text = { Text(stringResource(Res.string.completions_error_loading_contractors)) },
onClick = {},
enabled = false
)
@@ -150,16 +181,16 @@ fun CompleteTaskDialog(
OutlinedTextField(
value = completedByName,
onValueChange = { completedByName = it },
label = { Text("Completed By Name (optional)") },
label = { Text(stringResource(Res.string.completions_completed_by_name)) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Enter name if not using contractor above") },
placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
enabled = selectedContractorId == null
)
OutlinedTextField(
value = actualCost,
onValueChange = { actualCost = it },
label = { Text("Actual Cost (optional)") },
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") }
@@ -168,84 +199,151 @@ fun CompleteTaskDialog(
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (optional)") },
label = { Text(stringResource(Res.string.completions_notes_optional)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
// Quality Rating Section - Interactive Stars
Column {
Text("Rating: $rating out of 5")
Slider(
value = rating.toFloat(),
onValueChange = { rating = it.toInt() },
valueRange = 1f..5f,
steps = 3,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.completions_quality_rating),
style = MaterialTheme.typography.labelMedium
)
Text(
text = "$rating / 5",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
// Interactive Star Rating
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
(1..5).forEach { star ->
val isSelected = star <= rating
val starColor by animateColorAsState(
targetValue = if (isSelected) Color(0xFFFFD700) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
animationSpec = tween(durationMillis = 150),
label = "starColor"
)
IconButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Selection)
rating = star
},
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = "$star stars",
tint = starColor,
modifier = Modifier.size(32.dp)
)
}
}
}
}
// Image upload section
// Image upload section with thumbnails
Column {
Text(
text = "Add Images",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
style = MaterialTheme.typography.labelMedium
)
}
Spacer(modifier = Modifier.height(8.dp))
// Photo buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { cameraPicker() },
modifier = Modifier.weight(1f)
onClick = {
hapticFeedback.perform(HapticFeedbackType.Light)
cameraPicker()
},
modifier = Modifier.weight(1f),
enabled = selectedImages.size < MAX_IMAGES
) {
Text("Take Photo")
Icon(
Icons.Default.CameraAlt,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.completions_camera))
}
OutlinedButton(
onClick = { imagePicker() },
modifier = Modifier.weight(1f)
onClick = {
hapticFeedback.perform(HapticFeedbackType.Light)
imagePicker()
},
modifier = Modifier.weight(1f),
enabled = selectedImages.size < MAX_IMAGES
) {
Text("Choose from Library")
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.completions_library))
}
}
// Display selected images
// Image thumbnails with preview
if (selectedImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${selectedImages.size} image(s) selected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
selectedImages.forEach { image ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(
text = image.fileName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
selectedImages.forEachIndexed { index, imageData ->
ImageThumbnail(
imageData = imageData,
onRemove = {
hapticFeedback.perform(HapticFeedbackType.Light)
selectedImages = selectedImages.toMutableList().also {
it.removeAt(index)
}
},
removeContentDescription = removeImageDesc
)
IconButton(
onClick = {
selectedImages = selectedImages.filter { it != image }
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove image",
modifier = Modifier.size(16.dp)
)
}
}
}
}
// Helper text
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(Res.string.completions_add_photos_helper),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
@@ -281,12 +379,12 @@ fun CompleteTaskDialog(
)
}
) {
Text("Complete")
Text(stringResource(Res.string.completions_complete_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(cancelText)
}
}
)
@@ -296,3 +394,63 @@ fun CompleteTaskDialog(
private fun getCurrentDateTime(): String {
return kotlinx.datetime.LocalDate.toString()
}
/**
* Image thumbnail with remove button for displaying selected images.
*/
@Composable
private fun ImageThumbnail(
imageData: ImageData,
onRemove: () -> Unit,
removeContentDescription: String
) {
val imageBitmap = rememberImageBitmap(imageData)
Box(
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
if (imageBitmap != null) {
Image(
bitmap = imageBitmap,
contentDescription = imageData.fileName,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Fallback placeholder
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(32.dp)
)
}
}
// Remove button
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error)
.clickable(onClick = onRemove),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Close,
contentDescription = removeContentDescription,
tint = MaterialTheme.colorScheme.onError,
modifier = Modifier.size(14.dp)
)
}
}
}

View File

@@ -26,7 +26,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import casera.composeapp.generated.resources.*
import com.example.casera.models.SharedContractor
import org.jetbrains.compose.resources.stringResource
/**
* Dialog shown when a user attempts to import a contractor from a .casera file.
@@ -51,7 +53,7 @@ fun ContractorImportConfirmDialog(
},
title = {
Text(
text = "Import Contractor",
text = stringResource(Res.string.contractors_import_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
@@ -62,7 +64,7 @@ fun ContractorImportConfirmDialog(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Would you like to import this contractor?",
text = stringResource(Res.string.contractors_import_message),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@@ -100,7 +102,7 @@ fun ContractorImportConfirmDialog(
sharedContractor.exportedBy?.let { exportedBy ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Shared by: $exportedBy",
text = stringResource(Res.string.contractors_shared_by, exportedBy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -123,9 +125,9 @@ fun ContractorImportConfirmDialog(
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text("Importing...")
Text(stringResource(Res.string.common_importing))
} else {
Text("Import")
Text(stringResource(Res.string.common_import))
}
}
},
@@ -134,7 +136,7 @@ fun ContractorImportConfirmDialog(
onClick = onDismiss,
enabled = !isImporting
) {
Text("Cancel")
Text(stringResource(Res.string.common_cancel))
}
}
)
@@ -160,14 +162,14 @@ fun ContractorImportSuccessDialog(
},
title = {
Text(
text = "Contractor Imported",
text = stringResource(Res.string.contractors_import_success),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
},
text = {
Text(
text = "$contractorName has been added to your contacts.",
text = stringResource(Res.string.contractors_import_success_message, contractorName),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@@ -179,7 +181,7 @@ fun ContractorImportSuccessDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("OK")
Text(stringResource(Res.string.common_ok))
}
}
)
@@ -206,7 +208,7 @@ fun ContractorImportErrorDialog(
},
title = {
Text(
text = "Import Failed",
text = stringResource(Res.string.contractors_import_failed),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
@@ -230,7 +232,7 @@ fun ContractorImportErrorDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("Try Again")
Text(stringResource(Res.string.common_try_again))
}
} else {
Button(
@@ -239,14 +241,14 @@ fun ContractorImportErrorDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("OK")
Text(stringResource(Res.string.common_ok))
}
}
},
dismissButton = {
if (onRetry != null) {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(Res.string.common_cancel))
}
}
}

View File

@@ -23,7 +23,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import casera.composeapp.generated.resources.*
import com.example.casera.models.SharedResidence
import org.jetbrains.compose.resources.stringResource
/**
* Dialog shown when a user attempts to join a residence from a .casera file.
@@ -48,7 +50,7 @@ fun ResidenceImportConfirmDialog(
},
title = {
Text(
text = "Join Residence",
text = stringResource(Res.string.properties_join_residence_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
@@ -59,7 +61,7 @@ fun ResidenceImportConfirmDialog(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Would you like to join this shared residence?",
text = stringResource(Res.string.properties_join_residence_message),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@@ -80,7 +82,7 @@ fun ResidenceImportConfirmDialog(
sharedResidence.sharedBy?.let { sharedBy ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Shared by: $sharedBy",
text = stringResource(Res.string.properties_shared_by, sharedBy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -89,7 +91,7 @@ fun ResidenceImportConfirmDialog(
sharedResidence.expiresAt?.let { expiresAt ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Expires: $expiresAt",
text = stringResource(Res.string.properties_expires, expiresAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -112,9 +114,9 @@ fun ResidenceImportConfirmDialog(
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text("Joining...")
Text(stringResource(Res.string.properties_joining))
} else {
Text("Join")
Text(stringResource(Res.string.properties_join_button))
}
}
},
@@ -123,7 +125,7 @@ fun ResidenceImportConfirmDialog(
onClick = onDismiss,
enabled = !isImporting
) {
Text("Cancel")
Text(stringResource(Res.string.common_cancel))
}
}
)
@@ -149,14 +151,14 @@ fun ResidenceImportSuccessDialog(
},
title = {
Text(
text = "Joined Residence",
text = stringResource(Res.string.properties_join_success),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
},
text = {
Text(
text = "You now have access to $residenceName.",
text = stringResource(Res.string.properties_join_success_message, residenceName),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@@ -168,7 +170,7 @@ fun ResidenceImportSuccessDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("OK")
Text(stringResource(Res.string.common_ok))
}
}
)
@@ -195,7 +197,7 @@ fun ResidenceImportErrorDialog(
},
title = {
Text(
text = "Join Failed",
text = stringResource(Res.string.properties_join_failed),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
@@ -219,7 +221,7 @@ fun ResidenceImportErrorDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("Try Again")
Text(stringResource(Res.string.common_try_again))
}
} else {
Button(
@@ -228,14 +230,14 @@ fun ResidenceImportErrorDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("OK")
Text(stringResource(Res.string.common_ok))
}
}
},
dismissButton = {
if (onRetry != null) {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(stringResource(Res.string.common_cancel))
}
}
}

View File

@@ -13,9 +13,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import casera.composeapp.generated.resources.*
import com.example.casera.data.DataManager
import com.example.casera.models.TaskTemplate
import com.example.casera.models.TaskTemplateCategoryGroup
import org.jetbrains.compose.resources.stringResource
/**
* Bottom sheet for browsing all task templates from backend.
@@ -59,12 +61,12 @@ fun TaskTemplatesBrowserSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Task Templates",
text = stringResource(Res.string.templates_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TextButton(onClick = onDismiss) {
Text("Done")
Text(stringResource(Res.string.templates_done))
}
}
@@ -75,14 +77,14 @@ fun TaskTemplatesBrowserSheet(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search templates...") },
placeholder = { Text(stringResource(Res.string.templates_search_placeholder)) },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
Icon(Icons.Default.Clear, contentDescription = stringResource(Res.string.templates_clear))
}
}
},
@@ -104,8 +106,13 @@ fun TaskTemplatesBrowserSheet(
}
} else {
item {
val resultsText = if (filteredTemplates.size == 1) {
stringResource(Res.string.templates_result)
} else {
stringResource(Res.string.templates_results)
}
Text(
text = "${filteredTemplates.size} ${if (filteredTemplates.size == 1) "result" else "results"}",
text = "${filteredTemplates.size} $resultsText",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
@@ -226,7 +233,7 @@ private fun CategoryHeader(
// Expand/collapse indicator
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand",
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -288,7 +295,7 @@ private fun TaskTemplateItem(
// Add indicator
Icon(
imageVector = Icons.Default.AddCircleOutline,
contentDescription = "Add",
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
@@ -311,12 +318,12 @@ private fun EmptySearchState() {
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = "No Templates Found",
text = stringResource(Res.string.templates_no_results_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Try a different search term",
text = stringResource(Res.string.templates_no_results_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -339,12 +346,12 @@ private fun EmptyTemplatesState() {
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = "No Templates Available",
text = stringResource(Res.string.templates_empty_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Templates will appear here once loaded",
text = stringResource(Res.string.templates_empty_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -0,0 +1,15 @@
package com.example.casera.ui.components.auth
import androidx.compose.runtime.Composable
/**
* Google Sign In button - only shows on Android platform.
* On other platforms, this composable shows nothing.
*/
@Composable
expect fun GoogleSignInButton(
onSignInStarted: () -> Unit,
onSignInSuccess: (idToken: String) -> Unit,
onSignInError: (message: String) -> Unit,
enabled: Boolean = true
)

View File

@@ -21,6 +21,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.ui.theme.*
import com.example.casera.platform.HapticFeedbackType
import com.example.casera.platform.rememberHapticFeedback
/**
* ThemePickerDialog - Shows all available themes in a grid
@@ -52,6 +54,8 @@ fun ThemePickerDialog(
onThemeSelected: (ThemeColors) -> Unit,
onDismiss: () -> Unit
) {
val hapticFeedback = rememberHapticFeedback()
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(AppRadius.lg),
@@ -84,7 +88,10 @@ fun ThemePickerDialog(
ThemeCard(
theme = theme,
isSelected = theme.id == currentTheme.id,
onClick = { onThemeSelected(theme) }
onClick = {
hapticFeedback.perform(HapticFeedbackType.Selection)
onThemeSelected(theme)
}
)
}
}

View File

@@ -23,6 +23,7 @@ import com.example.casera.utils.SubscriptionHelper
@Composable
fun DocumentsTabContent(
state: ApiResult<List<Document>>,
filteredDocuments: List<Document> = emptyList(),
isWarrantyTab: Boolean,
onDocumentClick: (Int) -> Unit,
onRetry: () -> Unit,
@@ -48,7 +49,8 @@ fun DocumentsTabContent(
}
}
is ApiResult.Success -> {
val documents = state.data
// Use filteredDocuments if provided, otherwise fall back to state.data
val documents = if (filteredDocuments.isNotEmpty() || state.data.isEmpty()) filteredDocuments else state.data
if (documents.isEmpty()) {
if (shouldShowUpgradePrompt) {
// Free tier users see upgrade prompt

View File

@@ -8,7 +8,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import casera.composeapp.generated.resources.*
import com.example.casera.viewmodel.TaskViewModel
import org.jetbrains.compose.resources.stringResource
// MARK: - Edit Task Button
@Composable
@@ -18,6 +20,8 @@ fun EditTaskButton(
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
val editText = stringResource(Res.string.common_edit)
Button(
onClick = {
// Edit navigates to edit screen - handled by parent
@@ -31,11 +35,11 @@ fun EditTaskButton(
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit",
contentDescription = editText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Edit", style = MaterialTheme.typography.labelLarge)
Text(editText, style = MaterialTheme.typography.labelLarge)
}
}
@@ -48,13 +52,16 @@ fun CancelTaskButton(
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
val cancelText = stringResource(Res.string.tasks_cancel)
val errorMessage = stringResource(Res.string.tasks_failed_to_cancel)
OutlinedButton(
onClick = {
viewModel.cancelTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to cancel task")
onError(errorMessage)
}
}
},
@@ -65,11 +72,11 @@ fun CancelTaskButton(
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = "Cancel",
contentDescription = cancelText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Cancel", style = MaterialTheme.typography.labelLarge)
Text(cancelText, style = MaterialTheme.typography.labelLarge)
}
}
@@ -82,13 +89,16 @@ fun UncancelTaskButton(
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
val restoreText = stringResource(Res.string.tasks_uncancel)
val errorMessage = stringResource(Res.string.tasks_failed_to_restore)
Button(
onClick = {
viewModel.uncancelTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to restore task")
onError(errorMessage)
}
}
},
@@ -99,11 +109,11 @@ fun UncancelTaskButton(
) {
Icon(
imageVector = Icons.Default.Undo,
contentDescription = "Restore",
contentDescription = restoreText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Restore", style = MaterialTheme.typography.labelLarge)
Text(restoreText, style = MaterialTheme.typography.labelLarge)
}
}
@@ -116,13 +126,16 @@ fun MarkInProgressButton(
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
val inProgressText = stringResource(Res.string.tasks_in_progress_label)
val errorMessage = stringResource(Res.string.tasks_failed_to_mark_in_progress)
OutlinedButton(
onClick = {
viewModel.markInProgress(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to mark task in progress")
onError(errorMessage)
}
}
},
@@ -133,11 +146,11 @@ fun MarkInProgressButton(
) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = "Mark In Progress",
contentDescription = inProgressText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("In Progress", style = MaterialTheme.typography.labelLarge)
Text(inProgressText, style = MaterialTheme.typography.labelLarge)
}
}
@@ -149,6 +162,8 @@ fun CompleteTaskButton(
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
val completeText = stringResource(Res.string.tasks_mark_complete)
Button(
onClick = {
// Complete shows dialog - handled by parent
@@ -161,11 +176,11 @@ fun CompleteTaskButton(
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Complete",
contentDescription = completeText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Complete", style = MaterialTheme.typography.labelLarge)
Text(completeText, style = MaterialTheme.typography.labelLarge)
}
}
@@ -178,13 +193,16 @@ fun ArchiveTaskButton(
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
val archiveText = stringResource(Res.string.tasks_archive)
val errorMessage = stringResource(Res.string.tasks_failed_to_archive)
OutlinedButton(
onClick = {
viewModel.archiveTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to archive task")
onError(errorMessage)
}
}
},
@@ -195,11 +213,11 @@ fun ArchiveTaskButton(
) {
Icon(
imageVector = Icons.Default.Archive,
contentDescription = "Archive",
contentDescription = archiveText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Archive", style = MaterialTheme.typography.labelLarge)
Text(archiveText, style = MaterialTheme.typography.labelLarge)
}
}
@@ -212,13 +230,16 @@ fun UnarchiveTaskButton(
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
val unarchiveText = stringResource(Res.string.tasks_unarchive)
val errorMessage = stringResource(Res.string.tasks_failed_to_unarchive)
Button(
onClick = {
viewModel.unarchiveTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to unarchive task")
onError(errorMessage)
}
}
},
@@ -230,10 +251,10 @@ fun UnarchiveTaskButton(
) {
Icon(
imageVector = Icons.Default.Unarchive,
contentDescription = "Unarchive",
contentDescription = unarchiveText,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Unarchive", style = MaterialTheme.typography.labelLarge)
Text(unarchiveText, style = MaterialTheme.typography.labelLarge)
}
}

View File

@@ -14,12 +14,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import casera.composeapp.generated.resources.*
import com.example.casera.models.TaskDetail
import com.example.casera.models.TaskCategory
import com.example.casera.models.TaskPriority
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskCompletion
import com.example.casera.util.DateUtils
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@@ -115,7 +117,7 @@ fun TaskCard(
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "IN PROGRESS",
text = stringResource(Res.string.tasks_card_in_progress),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = statusColor
@@ -161,7 +163,7 @@ fun TaskCard(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: "N/A",
text = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: stringResource(Res.string.tasks_card_not_available),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -225,7 +227,7 @@ fun TaskCard(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Actions",
text = stringResource(Res.string.tasks_card_actions),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
@@ -337,7 +339,7 @@ private fun getActionMenuItem(
"mark_in_progress" -> {
onMarkInProgressClick?.let {
DropdownMenuItem(
text = { Text("Mark In Progress") },
text = { Text(stringResource(Res.string.tasks_card_mark_in_progress)) },
leadingIcon = {
Icon(Icons.Default.PlayArrow, contentDescription = null)
},
@@ -351,7 +353,7 @@ private fun getActionMenuItem(
"complete" -> {
onCompleteClick?.let {
DropdownMenuItem(
text = { Text("Complete Task") },
text = { Text(stringResource(Res.string.tasks_card_complete_task)) },
leadingIcon = {
Icon(Icons.Default.CheckCircle, contentDescription = null)
},
@@ -365,7 +367,7 @@ private fun getActionMenuItem(
"edit" -> {
onEditClick?.let {
DropdownMenuItem(
text = { Text("Edit Task") },
text = { Text(stringResource(Res.string.tasks_card_edit_task)) },
leadingIcon = {
Icon(Icons.Default.Edit, contentDescription = null)
},
@@ -379,7 +381,7 @@ private fun getActionMenuItem(
"cancel" -> {
onCancelClick?.let {
DropdownMenuItem(
text = { Text("Cancel Task") },
text = { Text(stringResource(Res.string.tasks_card_cancel_task)) },
leadingIcon = {
Icon(
Icons.Default.Cancel,
@@ -397,7 +399,7 @@ private fun getActionMenuItem(
"uncancel" -> {
onUncancelClick?.let {
DropdownMenuItem(
text = { Text("Restore Task") },
text = { Text(stringResource(Res.string.tasks_card_restore_task)) },
leadingIcon = {
Icon(Icons.Default.Undo, contentDescription = null)
},
@@ -411,7 +413,7 @@ private fun getActionMenuItem(
"archive" -> {
onArchiveClick?.let {
DropdownMenuItem(
text = { Text("Archive Task") },
text = { Text(stringResource(Res.string.tasks_card_archive_task)) },
leadingIcon = {
Icon(Icons.Default.Archive, contentDescription = null)
},
@@ -425,7 +427,7 @@ private fun getActionMenuItem(
"unarchive" -> {
onUnarchiveClick?.let {
DropdownMenuItem(
text = { Text("Unarchive Task") },
text = { Text(stringResource(Res.string.tasks_card_unarchive_task)) },
leadingIcon = {
Icon(Icons.Default.Unarchive, contentDescription = null)
},
@@ -498,7 +500,7 @@ fun CompletionCard(completion: TaskCompletion) {
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
text = "By: ${contractor.name}",
text = stringResource(Res.string.tasks_card_completed_by, contractor.name),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
@@ -514,7 +516,7 @@ fun CompletionCard(completion: TaskCompletion) {
} ?: completion.completedByName?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "By: $it",
text = stringResource(Res.string.tasks_card_completed_by, it),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
@@ -522,7 +524,7 @@ fun CompletionCard(completion: TaskCompletion) {
completion.actualCost?.let {
Text(
text = "Cost: $$it",
text = stringResource(Res.string.tasks_card_cost, it.toString()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
@@ -559,7 +561,7 @@ fun CompletionCard(completion: TaskCompletion) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "View Photos (${completion.images?.size ?: 0})",
text = stringResource(Res.string.tasks_card_view_photos, completion.images?.size ?: 0),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)

View File

@@ -258,10 +258,20 @@ fun DynamicTaskKanbanView(
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?,
modifier: Modifier = Modifier,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp,
scrollToColumnIndex: Int? = null,
onScrollComplete: () -> Unit = {}
) {
val pagerState = rememberPagerState(pageCount = { columns.size })
// Handle scrolling to a specific column when requested (e.g., from push notification)
LaunchedEffect(scrollToColumnIndex) {
if (scrollToColumnIndex != null && scrollToColumnIndex in columns.indices) {
pagerState.animateScrollToPage(scrollToColumnIndex)
onScrollComplete()
}
}
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxSize(),

View File

@@ -32,7 +32,9 @@ fun AllTasksScreen(
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp,
navigateToTaskId: Int? = null,
onClearNavigateToTask: () -> Unit = {}
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
@@ -43,11 +45,32 @@ fun AllTasksScreen(
var showNewTaskDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
// Track which column to scroll to (from push notification navigation)
var scrollToColumnIndex by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(Unit) {
viewModel.loadTasks()
residenceViewModel.loadMyResidences()
}
// When tasks load and we have a pending navigation, find the column containing the task
LaunchedEffect(navigateToTaskId, tasksState) {
if (navigateToTaskId != null && tasksState is ApiResult.Success) {
val taskData = (tasksState as ApiResult.Success).data
// Find which column contains the task
taskData.columns.forEachIndexed { index, column ->
if (column.tasks.any { it.id == navigateToTaskId }) {
println("📬 Found task $navigateToTaskId in column $index '${column.name}'")
scrollToColumnIndex = index
return@LaunchedEffect
}
}
// Task not found in any column
println("📬 Task $navigateToTaskId not found in any column")
onClearNavigateToTask()
}
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
@@ -224,7 +247,12 @@ fun AllTasksScreen(
}
},
modifier = Modifier,
bottomPadding = bottomNavBarPadding
bottomPadding = bottomNavBarPadding,
scrollToColumnIndex = scrollToColumnIndex,
onScrollComplete = {
scrollToColumnIndex = null
onClearNavigateToTask()
}
)
}
}

View File

@@ -28,6 +28,8 @@ import com.example.casera.platform.rememberImagePicker
import com.example.casera.platform.rememberCameraPicker
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -184,16 +186,16 @@ fun DocumentFormScreen(
title = {
Text(
when {
isEditMode && isWarranty -> "Edit Warranty"
isEditMode -> "Edit Document"
isWarranty -> "Add Warranty"
else -> "Add Document"
isEditMode && isWarranty -> stringResource(Res.string.documents_form_edit_warranty)
isEditMode -> stringResource(Res.string.documents_form_edit_document)
isWarranty -> stringResource(Res.string.documents_form_add_warranty)
else -> stringResource(Res.string.documents_form_add_document)
}
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
Icon(Icons.Default.ArrowBack, stringResource(Res.string.common_back))
}
}
)
@@ -231,10 +233,10 @@ fun DocumentFormScreen(
onExpandedChange = { residenceExpanded = it }
) {
OutlinedTextField(
value = selectedResidence?.name ?: "Select Residence",
value = selectedResidence?.name ?: stringResource(Res.string.documents_form_select_residence),
onValueChange = {},
readOnly = true,
label = { Text("Residence *") },
label = { Text(stringResource(Res.string.documents_form_residence_required)) },
isError = residenceError.isNotEmpty(),
supportingText = if (residenceError.isNotEmpty()) {
{ Text(residenceError) }
@@ -261,7 +263,7 @@ fun DocumentFormScreen(
}
is ApiResult.Error -> {
Text(
"Failed to load residences: ${com.example.casera.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)}",
stringResource(Res.string.documents_form_failed_to_load_residences, com.example.casera.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)),
color = MaterialTheme.colorScheme.error
)
}
@@ -278,7 +280,7 @@ fun DocumentFormScreen(
value = DocumentType.fromValue(selectedDocumentType).displayName,
onValueChange = {},
readOnly = true,
label = { Text("Document Type *") },
label = { Text(stringResource(Res.string.documents_form_document_type_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = documentTypeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
@@ -305,7 +307,7 @@ fun DocumentFormScreen(
title = it
titleError = ""
},
label = { Text("Title *") },
label = { Text(stringResource(Res.string.documents_form_title_required)) },
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
@@ -321,7 +323,7 @@ fun DocumentFormScreen(
itemName = it
itemNameError = ""
},
label = { Text("Item Name *") },
label = { Text(stringResource(Res.string.documents_form_item_name_required)) },
isError = itemNameError.isNotEmpty(),
supportingText = if (itemNameError.isNotEmpty()) {
{ Text(itemNameError) }
@@ -332,14 +334,14 @@ fun DocumentFormScreen(
OutlinedTextField(
value = modelNumber,
onValueChange = { modelNumber = it },
label = { Text("Model Number") },
label = { Text(stringResource(Res.string.documents_form_model_number)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = serialNumber,
onValueChange = { serialNumber = it },
label = { Text("Serial Number") },
label = { Text(stringResource(Res.string.documents_form_serial_number)) },
modifier = Modifier.fillMaxWidth()
)
@@ -349,7 +351,7 @@ fun DocumentFormScreen(
provider = it
providerError = ""
},
label = { Text("Provider/Company *") },
label = { Text(stringResource(Res.string.documents_form_provider_required)) },
isError = providerError.isNotEmpty(),
supportingText = if (providerError.isNotEmpty()) {
{ Text(providerError) }
@@ -360,14 +362,14 @@ fun DocumentFormScreen(
OutlinedTextField(
value = providerContact,
onValueChange = { providerContact = it },
label = { Text("Provider Contact") },
label = { Text(stringResource(Res.string.documents_form_provider_contact)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimPhone,
onValueChange = { claimPhone = it },
label = { Text("Claim Phone") },
label = { Text(stringResource(Res.string.documents_form_claim_phone)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth()
)
@@ -375,7 +377,7 @@ fun DocumentFormScreen(
OutlinedTextField(
value = claimEmail,
onValueChange = { claimEmail = it },
label = { Text("Claim Email") },
label = { Text(stringResource(Res.string.documents_form_claim_email)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth()
)
@@ -383,7 +385,7 @@ fun DocumentFormScreen(
OutlinedTextField(
value = claimWebsite,
onValueChange = { claimWebsite = it },
label = { Text("Claim Website") },
label = { Text(stringResource(Res.string.documents_form_claim_website)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth()
)
@@ -391,24 +393,24 @@ fun DocumentFormScreen(
OutlinedTextField(
value = purchaseDate,
onValueChange = { purchaseDate = it },
label = { Text("Purchase Date (YYYY-MM-DD)") },
placeholder = { Text("2024-01-15") },
label = { Text(stringResource(Res.string.documents_form_purchase_date)) },
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = startDate,
onValueChange = { startDate = it },
label = { Text("Warranty Start Date (YYYY-MM-DD)") },
placeholder = { Text("2024-01-15") },
label = { Text(stringResource(Res.string.documents_form_warranty_start)) },
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = endDate,
onValueChange = { endDate = it },
label = { Text("Warranty End Date (YYYY-MM-DD) *") },
placeholder = { Text("2025-01-15") },
label = { Text(stringResource(Res.string.documents_form_warranty_end_required)) },
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder_end)) },
modifier = Modifier.fillMaxWidth()
)
}
@@ -417,7 +419,7 @@ fun DocumentFormScreen(
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
label = { Text(stringResource(Res.string.documents_form_description)) },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
@@ -429,10 +431,10 @@ fun DocumentFormScreen(
onExpandedChange = { categoryExpanded = it }
) {
OutlinedTextField(
value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: "Select Category",
value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: stringResource(Res.string.documents_form_select_category),
onValueChange = {},
readOnly = true,
label = { Text("Category") },
label = { Text(stringResource(Res.string.documents_form_category)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
@@ -441,7 +443,7 @@ fun DocumentFormScreen(
onDismissRequest = { categoryExpanded = false }
) {
DropdownMenuItem(
text = { Text("None") },
text = { Text(stringResource(Res.string.documents_form_category_none)) },
onClick = {
selectedCategory = null
categoryExpanded = false
@@ -464,8 +466,8 @@ fun DocumentFormScreen(
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
label = { Text("Tags") },
placeholder = { Text("tag1, tag2, tag3") },
label = { Text(stringResource(Res.string.documents_form_tags)) },
placeholder = { Text(stringResource(Res.string.documents_form_tags_placeholder)) },
modifier = Modifier.fillMaxWidth()
)
@@ -473,7 +475,7 @@ fun DocumentFormScreen(
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes") },
label = { Text(stringResource(Res.string.documents_form_notes)) },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
@@ -485,7 +487,7 @@ fun DocumentFormScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Active")
Text(stringResource(Res.string.documents_form_active))
Switch(
checked = isActive,
onCheckedChange = { isActive = it }
@@ -506,7 +508,7 @@ fun DocumentFormScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Existing Photos (${existingImages.size})",
stringResource(Res.string.documents_form_existing_photos, existingImages.size),
style = MaterialTheme.typography.titleSmall
)
@@ -538,7 +540,11 @@ fun DocumentFormScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"${if (isEditMode) "New " else ""}Photos (${selectedImages.size}/$maxImages)",
if (isEditMode) {
stringResource(Res.string.documents_form_new_photos, selectedImages.size, maxImages)
} else {
stringResource(Res.string.documents_form_photos, selectedImages.size, maxImages)
},
style = MaterialTheme.typography.titleSmall
)
@@ -552,7 +558,7 @@ fun DocumentFormScreen(
) {
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Camera")
Text(stringResource(Res.string.documents_form_camera))
}
Button(
@@ -562,7 +568,7 @@ fun DocumentFormScreen(
) {
Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Gallery")
Text(stringResource(Res.string.documents_form_gallery))
}
}
@@ -587,7 +593,7 @@ fun DocumentFormScreen(
tint = MaterialTheme.colorScheme.primary
)
Text(
"Image ${index + 1}",
stringResource(Res.string.documents_form_image_number, index + 1),
style = MaterialTheme.typography.bodyMedium
)
}
@@ -598,7 +604,7 @@ fun DocumentFormScreen(
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove image",
contentDescription = stringResource(Res.string.documents_form_remove_image),
tint = MaterialTheme.colorScheme.error
)
}
@@ -625,6 +631,12 @@ fun DocumentFormScreen(
}
}
// Error messages (need to be defined outside onClick)
val selectResidenceError = stringResource(Res.string.documents_form_select_residence_error)
val titleRequiredError = stringResource(Res.string.documents_form_title_error)
val itemRequiredError = stringResource(Res.string.documents_form_item_name_error)
val providerRequiredError = stringResource(Res.string.documents_form_provider_error)
// Save Button
Button(
onClick = {
@@ -634,7 +646,7 @@ fun DocumentFormScreen(
// Determine the actual residenceId to use
val actualResidenceId = if (needsResidenceSelection) {
if (selectedResidence == null) {
residenceError = "Please select a residence"
residenceError = selectResidenceError
hasError = true
-1
} else {
@@ -645,17 +657,17 @@ fun DocumentFormScreen(
}
if (title.isBlank()) {
titleError = "Title is required"
titleError = titleRequiredError
hasError = true
}
if (isWarranty) {
if (itemName.isBlank()) {
itemNameError = "Item name is required for warranties"
itemNameError = itemRequiredError
hasError = true
}
if (provider.isBlank()) {
providerError = "Provider is required for warranties"
providerError = providerRequiredError
hasError = true
}
}
@@ -722,10 +734,10 @@ fun DocumentFormScreen(
} else {
Text(
when {
isEditMode && isWarranty -> "Update Warranty"
isEditMode -> "Update Document"
isWarranty -> "Add Warranty"
else -> "Add Document"
isEditMode && isWarranty -> stringResource(Res.string.documents_form_update_warranty)
isEditMode -> stringResource(Res.string.documents_form_update_document)
isWarranty -> stringResource(Res.string.documents_form_add_warranty)
else -> stringResource(Res.string.documents_form_add_document)
}
)
}

View File

@@ -51,27 +51,27 @@ fun DocumentsScreen(
LaunchedEffect(Unit) {
// Track screen view
PostHogAnalytics.screen(AnalyticsEvents.DOCUMENTS_SCREEN_SHOWN)
// Load warranties by default (documentType="warranty")
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
isActive = true
)
// Load all documents once - filtering happens client-side
documentViewModel.loadAllDocuments(residenceId = residenceId)
}
LaunchedEffect(selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
if (selectedTab == DocumentTab.WARRANTIES) {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
} else {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = selectedDocType
)
// Client-side filtering - no API calls on filter changes
val filteredDocuments = remember(documentsState, selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
val allDocuments = (documentsState as? com.example.casera.network.ApiResult.Success)?.data ?: emptyList()
allDocuments.filter { document ->
val matchesTab = if (selectedTab == DocumentTab.WARRANTIES) {
document.documentType == "warranty"
} else {
document.documentType != "warranty"
}
val matchesCategory = selectedCategory == null || document.category == selectedCategory
val matchesDocType = selectedDocType == null || document.documentType == selectedDocType
val matchesActive = if (selectedTab == DocumentTab.WARRANTIES && showActiveOnly) {
document.isActive == true
} else {
true
}
matchesTab && matchesCategory && matchesDocType && matchesActive
}
}
@@ -212,39 +212,18 @@ fun DocumentsScreen(
onNavigateBack = onNavigateBack
)
} else {
// Pro users see normal content
when (selectedTab) {
DocumentTab.WARRANTIES -> {
DocumentsTabContent(
state = documentsState,
isWarrantyTab = true,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
},
onNavigateBack = onNavigateBack
)
}
DocumentTab.DOCUMENTS -> {
DocumentsTabContent(
state = documentsState,
isWarrantyTab = false,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = selectedDocType
)
},
onNavigateBack = onNavigateBack
)
}
}
// Pro users see normal content - use client-side filtered documents
DocumentsTabContent(
state = documentsState,
filteredDocuments = filteredDocuments,
isWarrantyTab = selectedTab == DocumentTab.WARRANTIES,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
// Reload all documents on pull-to-refresh
documentViewModel.loadAllDocuments(residenceId = residenceId)
},
onNavigateBack = onNavigateBack
)
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.auth.GoogleSignInButton
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@@ -41,7 +42,9 @@ fun LoginScreen(
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var googleSignInError by remember { mutableStateOf<String?>(null) }
val loginState by viewModel.loginState.collectAsState()
val googleSignInState by viewModel.googleSignInState.collectAsState()
// Handle errors for login
loginState.HandleErrors(
@@ -63,12 +66,32 @@ fun LoginScreen(
}
}
val errorMessage = when (loginState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
// Handle Google Sign In state changes
LaunchedEffect(googleSignInState) {
when (googleSignInState) {
is ApiResult.Success -> {
val response = (googleSignInState as ApiResult.Success).data
// Track successful Google sign in
PostHogAnalytics.capture(AnalyticsEvents.USER_SIGNED_IN, mapOf("method" to "google", "is_new_user" to response.isNewUser))
PostHogAnalytics.identify(response.user.id.toString(), mapOf("email" to (response.user.email ?: ""), "username" to (response.user.username ?: "")))
viewModel.resetGoogleSignInState()
onLoginSuccess(response.user)
}
is ApiResult.Error -> {
googleSignInError = com.example.casera.util.ErrorMessageParser.parse((googleSignInState as ApiResult.Error).message)
viewModel.resetGoogleSignInState()
}
else -> {}
}
}
val errorMessage = when {
loginState is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
googleSignInError != null -> googleSignInError ?: ""
else -> ""
}
val isLoading = loginState is ApiResult.Loading
val isLoading = loginState is ApiResult.Loading || googleSignInState is ApiResult.Loading
Box(
modifier = Modifier
@@ -140,6 +163,11 @@ fun LoginScreen(
ErrorCard(message = errorMessage)
// Clear Google error when user starts typing
LaunchedEffect(username, password) {
googleSignInError = null
}
// Gradient button
Box(
modifier = Modifier
@@ -191,6 +219,41 @@ fun LoginScreen(
}
}
// Divider with "or"
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
Text(
text = "or",
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
// Google Sign In button (only shows on Android)
GoogleSignInButton(
onSignInStarted = {
googleSignInError = null
},
onSignInSuccess = { idToken ->
viewModel.googleSignIn(idToken)
},
onSignInError = { error ->
googleSignInError = error
},
enabled = !isLoading
)
TextButton(
onClick = onNavigateToForgotPassword,
modifier = Modifier.fillMaxWidth()

View File

@@ -25,11 +25,23 @@ fun MainScreen(
onAddResidence: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (com.example.casera.models.TaskDetail) -> Unit,
onAddTask: () -> Unit
onAddTask: () -> Unit,
navigateToTaskId: Int? = null,
onClearNavigateToTask: () -> Unit = {}
) {
var selectedTab by remember { mutableStateOf(0) }
val navController = rememberNavController()
// When navigateToTaskId is set, switch to tasks tab
LaunchedEffect(navigateToTaskId) {
if (navigateToTaskId != null) {
selectedTab = 1
navController.navigate(MainTabTasksRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
}
}
Scaffold(
bottomBar = {
NavigationBar(
@@ -141,7 +153,7 @@ fun MainScreen(
onAddResidence = onAddResidence,
onLogout = onLogout,
onNavigateToProfile = {
selectedTab = 3
// Don't change selectedTab since Profile isn't in the bottom nav
navController.navigate(MainTabProfileRoute)
}
)
@@ -153,7 +165,9 @@ fun MainScreen(
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding()
bottomNavBarPadding = paddingValues.calculateBottomPadding(),
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = onClearNavigateToTask
)
}
}

View File

@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.example.casera.data.DataManager
import com.example.casera.models.AppleSignInRequest
import com.example.casera.models.AppleSignInResponse
import com.example.casera.models.GoogleSignInRequest
import com.example.casera.models.GoogleSignInResponse
import com.example.casera.models.AuthResponse
import com.example.casera.models.ForgotPasswordRequest
import com.example.casera.models.ForgotPasswordResponse
@@ -53,6 +55,9 @@ class AuthViewModel : ViewModel() {
private val _appleSignInState = MutableStateFlow<ApiResult<AppleSignInResponse>>(ApiResult.Idle)
val appleSignInState: StateFlow<ApiResult<AppleSignInResponse>> = _appleSignInState
private val _googleSignInState = MutableStateFlow<ApiResult<GoogleSignInResponse>>(ApiResult.Idle)
val googleSignInState: StateFlow<ApiResult<GoogleSignInResponse>> = _googleSignInState
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
@@ -241,6 +246,25 @@ class AuthViewModel : ViewModel() {
_appleSignInState.value = ApiResult.Idle
}
fun googleSignIn(idToken: String) {
viewModelScope.launch {
_googleSignInState.value = ApiResult.Loading
val result = APILayer.googleSignIn(
GoogleSignInRequest(idToken = idToken)
)
// APILayer.googleSignIn already stores token in DataManager
_googleSignInState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
}
fun resetGoogleSignInState() {
_googleSignInState.value = ApiResult.Idle
}
fun logout() {
viewModelScope.launch {
// APILayer.logout clears DataManager

View File

@@ -60,6 +60,23 @@ class DocumentViewModel : ViewModel() {
}
}
/**
* Loads all documents without any filters - filtering is done client-side.
* This reduces API calls when switching tabs or applying filters.
*/
fun loadAllDocuments(
residenceId: Int? = null,
forceRefresh: Boolean = false
) {
viewModelScope.launch {
_documentsState.value = ApiResult.Loading
_documentsState.value = APILayer.getDocuments(
residenceId = residenceId,
forceRefresh = forceRefresh
)
}
}
fun loadDocumentDetail(id: Int) {
viewModelScope.launch {
_documentDetailState.value = ApiResult.Loading

View File

@@ -9,6 +9,7 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import platform.Foundation.NSLocale
import platform.Foundation.NSTimeZone
import platform.Foundation.localTimeZone
import platform.Foundation.preferredLanguages
actual fun getLocalhostAddress(): String = "127.0.0.1"

View File

@@ -0,0 +1,21 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* iOS implementation - no-op since iOS haptics are handled by SwiftUI.
* This is only used when running the shared Compose code on iOS
* (which isn't the primary iOS UI).
*/
class IOSHapticFeedbackPerformer : HapticFeedbackPerformer {
override fun perform(type: HapticFeedbackType) {
// iOS haptic feedback is handled natively in SwiftUI views
// This is a no-op for the Compose layer on iOS
}
}
@Composable
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
return remember { IOSHapticFeedbackPerformer() }
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
@Composable
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
return remember(imageData) {
try {
val skiaImage = Image.makeFromEncoded(imageData.bytes)
skiaImage.toComposeImageBitmap()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.ui.components.auth
import androidx.compose.runtime.Composable
/**
* iOS stub - Google Sign In is not available on iOS (use Apple Sign In instead)
*/
@Composable
actual fun GoogleSignInButton(
onSignInStarted: () -> Unit,
onSignInSuccess: (idToken: String) -> Unit,
onSignInError: (message: String) -> Unit,
enabled: Boolean
) {
// No-op on iOS - Apple Sign In is used instead
}

View File

@@ -0,0 +1,18 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* JS/Web implementation - no-op since web doesn't have haptic feedback.
*/
class JsHapticFeedbackPerformer : HapticFeedbackPerformer {
override fun perform(type: HapticFeedbackType) {
// Web doesn't support haptic feedback
}
}
@Composable
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
return remember { JsHapticFeedbackPerformer() }
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
@Composable
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
return remember(imageData) {
try {
val skiaImage = Image.makeFromEncoded(imageData.bytes)
skiaImage.toComposeImageBitmap()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.ui.components.auth
import androidx.compose.runtime.Composable
/**
* JS stub - Google Sign In not implemented for web JS target
*/
@Composable
actual fun GoogleSignInButton(
onSignInStarted: () -> Unit,
onSignInSuccess: (idToken: String) -> Unit,
onSignInError: (message: String) -> Unit,
enabled: Boolean
) {
// No-op on JS
}

View File

@@ -0,0 +1,18 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* JVM/Desktop implementation - no-op since desktop doesn't have haptic feedback.
*/
class JvmHapticFeedbackPerformer : HapticFeedbackPerformer {
override fun perform(type: HapticFeedbackType) {
// Desktop doesn't support haptic feedback
}
}
@Composable
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
return remember { JvmHapticFeedbackPerformer() }
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
@Composable
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
return remember(imageData) {
try {
val skiaImage = Image.makeFromEncoded(imageData.bytes)
skiaImage.toComposeImageBitmap()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.ui.components.auth
import androidx.compose.runtime.Composable
/**
* JVM/Desktop stub - Google Sign In not implemented for desktop
*/
@Composable
actual fun GoogleSignInButton(
onSignInStarted: () -> Unit,
onSignInSuccess: (idToken: String) -> Unit,
onSignInError: (message: String) -> Unit,
enabled: Boolean
) {
// No-op on JVM/Desktop
}

View File

@@ -0,0 +1,18 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* WASM/Web implementation - no-op since web doesn't have haptic feedback.
*/
class WasmJsHapticFeedbackPerformer : HapticFeedbackPerformer {
override fun perform(type: HapticFeedbackType) {
// Web doesn't support haptic feedback
}
}
@Composable
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
return remember { WasmJsHapticFeedbackPerformer() }
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
@Composable
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
return remember(imageData) {
try {
val skiaImage = Image.makeFromEncoded(imageData.bytes)
skiaImage.toComposeImageBitmap()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.ui.components.auth
import androidx.compose.runtime.Composable
/**
* WASM stub - Google Sign In not implemented for web WASM target
*/
@Composable
actual fun GoogleSignInButton(
onSignInStarted: () -> Unit,
onSignInSuccess: (idToken: String) -> Unit,
onSignInError: (message: String) -> Unit,
enabled: Boolean
) {
// No-op on WASM
}