diff --git a/TODO-string-localization.md b/TODO-string-localization.md new file mode 100644 index 0000000..b97d710 --- /dev/null +++ b/TODO-string-localization.md @@ -0,0 +1,146 @@ +# String Localization Migration TODO + +This file tracks the remaining work to migrate hardcoded strings to Compose Resources (`composeApp/src/commonMain/composeResources/values/strings.xml`). + +## Completed ✅ + +### High Priority Files (Done) +- [x] `DocumentFormScreen.kt` - 48 strings migrated +- [x] `AddTaskDialog.kt` - 28 strings migrated + +## Remaining Work + +### Priority 1: Dialogs with Many Strings + +#### AddContractorDialog.kt (~25 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt` + +Strings to migrate: +- Dialog title: "Add Contractor" +- Field labels: Name *, Company, Phone, Email, Specialty, Notes, Website, Address +- Validation errors: "Name is required" +- Buttons: "Create", "Cancel" + +#### CompleteTaskDialog.kt (~22 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt` + +Strings to migrate: +- Dialog title: "Complete Task" +- Field labels: Notes, Actual Cost, Completion Date +- Photo section: "Photos", "Camera", "Gallery", "Remove" +- Buttons: "Complete", "Cancel" +- Validation messages + +### Priority 2: Import/Share Dialogs (~14 strings) + +#### ContractorImportDialog.kt (~7 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt` + +#### ResidenceImportDialog.kt (~7 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt` + +### Priority 3: Task Components (~14 strings) + +#### TaskActionButtons.kt (~7 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt` + +#### TaskCard.kt (~7 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt` + +### Priority 4: Other Dialogs (~10 strings) + +#### JoinResidenceDialog.kt (~7 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt` + +#### ManageUsersDialog.kt (~2 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt` + +#### TaskTemplatesBrowserSheet.kt (~3 strings) +Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt` + +### Priority 5: Smaller Components (~15 strings total) + +Files with 1-3 hardcoded strings each: +- `InfoCard.kt` +- `FeatureComparisonDialog.kt` +- `ThemePickerDialog.kt` +- `StandardCard.kt` +- `CompactCard.kt` +- `ApiResultHandler.kt` +- `DocumentCard.kt` +- `DocumentStates.kt` +- `CompletionHistorySheet.kt` +- `DocumentDetailScreen.kt` +- `EditTaskScreen.kt` +- `MainScreen.kt` +- `UpgradePromptDialog.kt` +- `VerifyEmailScreen.kt` +- `VerifyResetCodeScreen.kt` +- `UpgradeFeatureScreen.kt` +- `ResidenceFormScreen.kt` + +## How to Migrate Strings + +### 1. Add import to the file: +```kotlin +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource +``` + +### 2. Add string to strings.xml: +```xml +Field Label +``` + +### 3. Replace hardcoded string: +```kotlin +// Before +Text("Field Label") + +// After +Text(stringResource(Res.string.component_field_label)) +``` + +### 4. For strings with parameters: +```xml +%1$d items +``` +```kotlin +Text(stringResource(Res.string.items_count, count)) +``` + +### 5. For strings used in onClick handlers: +Define outside the lambda (stringResource is @Composable): +```kotlin +val errorMessage = stringResource(Res.string.error_message) + +Button(onClick = { + // Use errorMessage here + showError = errorMessage +}) +``` + +## Naming Convention + +Use this pattern for string names: +- `{component}_{field}` - e.g., `contractor_name_label` +- `{component}_{action}` - e.g., `contractor_create` +- `{component}_{error}` - e.g., `contractor_name_error` + +Existing prefixes in strings.xml: +- `auth_` - Authentication screens +- `properties_` - Residence/property screens +- `tasks_` - Task screens and components +- `contractors_` - Contractor screens +- `documents_` - Document screens +- `profile_` - Profile screens +- `common_` - Shared strings (Cancel, OK, Back, etc.) + +## Testing + +After migrating strings, run: +```bash +./gradlew :composeApp:compileDebugKotlinAndroid +``` + +Build should complete successfully with only deprecation warnings. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f8e6ee8..747aaf8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -56,6 +56,18 @@ kotlin { // PostHog Analytics implementation("com.posthog:posthog-android:3.8.2") + + // Google Sign In - Credential Manager + implementation("androidx.credentials:credentials:1.3.0") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1") + + // Jetpack Glance for Home Screen Widgets + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") + + // DataStore for widget data persistence + implementation("androidx.datastore:datastore-preferences:1.1.1") } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c1b4d44..5eac1e9 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -100,6 +100,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/auth/GoogleSignInManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/auth/GoogleSignInManager.kt new file mode 100644 index 0000000..6a67f6d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/auth/GoogleSignInManager.kt @@ -0,0 +1,92 @@ +package com.example.casera.auth + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialException +import com.example.casera.network.ApiConfig +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential + +/** + * Result of a Google Sign In attempt + */ +sealed class GoogleSignInResult { + data class Success(val idToken: String) : GoogleSignInResult() + data class Error(val message: String, val exception: Exception? = null) : GoogleSignInResult() + object Cancelled : GoogleSignInResult() +} + +/** + * Manager for Google Sign In using Android Credential Manager + */ +class GoogleSignInManager(private val context: Context) { + + private val credentialManager = CredentialManager.create(context) + + /** + * Initiates Google Sign In flow and returns the ID token + */ + suspend fun signIn(): GoogleSignInResult { + return try { + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(ApiConfig.GOOGLE_WEB_CLIENT_ID) + .setAutoSelectEnabled(true) + .build() + + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + val result = credentialManager.getCredential( + request = request, + context = context + ) + + handleSignInResult(result) + } catch (e: GetCredentialException) { + when { + e.message?.contains("cancelled", ignoreCase = true) == true || + e.message?.contains("user cancelled", ignoreCase = true) == true -> { + GoogleSignInResult.Cancelled + } + else -> { + GoogleSignInResult.Error("Sign in failed: ${e.message}", e) + } + } + } catch (e: Exception) { + GoogleSignInResult.Error("Unexpected error during sign in: ${e.message}", e) + } + } + + private fun handleSignInResult(result: GetCredentialResponse): GoogleSignInResult { + val credential = result.credential + + return when (credential) { + is CustomCredential -> { + if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + try { + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val idToken = googleIdTokenCredential.idToken + + if (idToken.isNotEmpty()) { + GoogleSignInResult.Success(idToken) + } else { + GoogleSignInResult.Error("Empty ID token received") + } + } catch (e: Exception) { + GoogleSignInResult.Error("Failed to parse Google credential: ${e.message}", e) + } + } else { + GoogleSignInResult.Error("Unexpected credential type: ${credential.type}") + } + } + else -> { + GoogleSignInResult.Error("Unexpected credential type") + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/HapticFeedback.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/HapticFeedback.android.kt new file mode 100644 index 0000000..2f70c76 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/HapticFeedback.android.kt @@ -0,0 +1,105 @@ +package com.example.casera.platform + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView + +/** + * Android implementation of haptic feedback using system vibrator. + */ +class AndroidHapticFeedbackPerformer( + private val view: View, + private val vibrator: Vibrator? +) : HapticFeedbackPerformer { + + override fun perform(type: HapticFeedbackType) { + // First try View-based haptic feedback (works best) + val hapticConstant = when (type) { + HapticFeedbackType.Light -> HapticFeedbackConstants.KEYBOARD_TAP + HapticFeedbackType.Medium -> HapticFeedbackConstants.CONTEXT_CLICK + HapticFeedbackType.Heavy -> HapticFeedbackConstants.LONG_PRESS + HapticFeedbackType.Selection -> HapticFeedbackConstants.CLOCK_TICK + HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.CONFIRM + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + HapticFeedbackType.Warning -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.REJECT + } else { + HapticFeedbackConstants.LONG_PRESS + } + HapticFeedbackType.Error -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.REJECT + } else { + HapticFeedbackConstants.LONG_PRESS + } + } + + val success = view.performHapticFeedback(hapticConstant) + + // Fallback to vibrator if view-based feedback fails + if (!success && vibrator?.hasVibrator() == true) { + performVibration(type) + } + } + + private fun performVibration(type: HapticFeedbackType) { + vibrator ?: return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = when (type) { + HapticFeedbackType.Light -> VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE) + HapticFeedbackType.Medium -> VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE) + HapticFeedbackType.Heavy -> VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE) + HapticFeedbackType.Selection -> VibrationEffect.createOneShot(5, VibrationEffect.DEFAULT_AMPLITUDE) + HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK) + } else { + VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE) + } + HapticFeedbackType.Warning -> VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE) + HapticFeedbackType.Error -> VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE) + } + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + val duration = when (type) { + HapticFeedbackType.Light -> 10L + HapticFeedbackType.Medium -> 20L + HapticFeedbackType.Heavy -> 50L + HapticFeedbackType.Selection -> 5L + HapticFeedbackType.Success -> 30L + HapticFeedbackType.Warning -> 40L + HapticFeedbackType.Error -> 60L + } + @Suppress("DEPRECATION") + vibrator.vibrate(duration) + } + } +} + +@Composable +actual fun rememberHapticFeedback(): HapticFeedbackPerformer { + val context = LocalContext.current + val view = LocalView.current + + return remember { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vibratorManager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + AndroidHapticFeedbackPerformer(view, vibrator) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ImageBitmap.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ImageBitmap.android.kt new file mode 100644 index 0000000..c987cd1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ImageBitmap.android.kt @@ -0,0 +1,19 @@ +package com.example.casera.platform + +import android.graphics.BitmapFactory +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap + +@Composable +actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? { + return remember(imageData) { + try { + val bitmap = BitmapFactory.decodeByteArray(imageData.bytes, 0, imageData.bytes.size) + bitmap?.asImageBitmap() + } catch (e: Exception) { + null + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.android.kt new file mode 100644 index 0000000..cfbf77b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.android.kt @@ -0,0 +1,101 @@ +package com.example.casera.ui.components.auth + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.casera.auth.GoogleSignInManager +import com.example.casera.auth.GoogleSignInResult +import kotlinx.coroutines.launch + +@Composable +actual fun GoogleSignInButton( + onSignInStarted: () -> Unit, + onSignInSuccess: (idToken: String) -> Unit, + onSignInError: (message: String) -> Unit, + enabled: Boolean +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(false) } + + val googleSignInManager = remember { GoogleSignInManager(context) } + + OutlinedButton( + onClick = { + if (!isLoading && enabled) { + isLoading = true + onSignInStarted() + + scope.launch { + when (val result = googleSignInManager.signIn()) { + is GoogleSignInResult.Success -> { + isLoading = false + onSignInSuccess(result.idToken) + } + is GoogleSignInResult.Error -> { + isLoading = false + onSignInError(result.message) + } + GoogleSignInResult.Cancelled -> { + isLoading = false + // User cancelled, no error needed + } + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = enabled && !isLoading, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // Google "G" logo using Material colors + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "G", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFF4285F4) // Google Blue + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = "Continue with Google", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraLargeWidget.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraLargeWidget.kt new file mode 100644 index 0000000..c8397dd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraLargeWidget.kt @@ -0,0 +1,367 @@ +package com.example.casera.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +/** + * Large widget showing task list with stats and interactive actions (Pro only) + * Size: 4x4 + */ +class CaseraLargeWidget : GlanceAppWidget() { + + override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + LargeWidgetContent() + } + } + } + + @Composable + private fun LargeWidgetContent() { + val prefs = currentState() + val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0 + val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0 + val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0 + val totalCount = prefs[intPreferencesKey("total_tasks_count")] ?: 0 + val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]" + val isProUser = prefs[stringPreferencesKey("is_pro_user")] == "true" + + val tasks = try { + json.decodeFromString>(tasksJson).take(8) + } catch (e: Exception) { + emptyList() + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(Color(0xFFFFF8E7)) // Cream background + .padding(16.dp) + ) { + Column( + modifier = GlanceModifier.fillMaxSize() + ) { + // Header with logo + Row( + modifier = GlanceModifier + .fillMaxWidth() + .clickable(actionRunCallback()), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Casera", + style = TextStyle( + color = ColorProvider(Color(0xFF07A0C3)), + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + Text( + text = "Tasks", + style = TextStyle( + color = ColorProvider(Color(0xFF666666)), + fontSize = 14.sp + ) + ) + } + + Spacer(modifier = GlanceModifier.height(12.dp)) + + // Stats row + Row( + modifier = GlanceModifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + StatBox( + count = overdueCount, + label = "Overdue", + color = Color(0xFFDD1C1A), + bgColor = Color(0xFFFFEBEB) + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + StatBox( + count = dueSoonCount, + label = "Due Soon", + color = Color(0xFFF5A623), + bgColor = Color(0xFFFFF4E0) + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + StatBox( + count = inProgressCount, + label = "Active", + color = Color(0xFF07A0C3), + bgColor = Color(0xFFE0F4F8) + ) + } + + Spacer(modifier = GlanceModifier.height(12.dp)) + + // Divider + Box( + modifier = GlanceModifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xFFE0E0E0)) + ) {} + + Spacer(modifier = GlanceModifier.height(8.dp)) + + // Task list + if (tasks.isEmpty()) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .clickable(actionRunCallback()), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "All caught up!", + style = TextStyle( + color = ColorProvider(Color(0xFF07A0C3)), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + ) + Text( + text = "No tasks need attention", + style = TextStyle( + color = ColorProvider(Color(0xFF888888)), + fontSize = 12.sp + ) + ) + } + } + } else { + LazyColumn( + modifier = GlanceModifier.fillMaxSize() + ) { + items(tasks) { task -> + InteractiveTaskItem( + task = task, + isProUser = isProUser + ) + } + } + } + } + } + } + + @Composable + private fun StatBox(count: Int, label: String, color: Color, bgColor: Color) { + Box( + modifier = GlanceModifier + .background(bgColor) + .padding(horizontal = 12.dp, vertical = 8.dp) + .cornerRadius(8.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = count.toString(), + style = TextStyle( + color = ColorProvider(color), + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + ) + Text( + text = label, + style = TextStyle( + color = ColorProvider(color), + fontSize = 10.sp + ) + ) + } + } + } + + @Composable + private fun InteractiveTaskItem(task: WidgetTask, isProUser: Boolean) { + val taskIdKey = ActionParameters.Key("task_id") + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable( + actionRunCallback( + actionParametersOf(taskIdKey to task.id) + ) + ), + verticalAlignment = Alignment.CenterVertically + ) { + // Priority indicator + Box( + modifier = GlanceModifier + .width(4.dp) + .height(40.dp) + .background(getPriorityColor(task.priorityLevel)) + ) {} + + Spacer(modifier = GlanceModifier.width(8.dp)) + + // Task details + Column( + modifier = GlanceModifier.defaultWeight() + ) { + Text( + text = task.title, + style = TextStyle( + color = ColorProvider(Color(0xFF1A1A1A)), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1 + ) + Row { + Text( + text = task.residenceName, + style = TextStyle( + color = ColorProvider(Color(0xFF666666)), + fontSize = 11.sp + ), + maxLines = 1 + ) + if (task.dueDate != null) { + Text( + text = " • ${task.dueDate}", + style = TextStyle( + color = ColorProvider( + if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666) + ), + fontSize = 11.sp + ) + ) + } + } + } + + // Action button (Pro only) + if (isProUser) { + Box( + modifier = GlanceModifier + .size(32.dp) + .background(Color(0xFF07A0C3)) + .cornerRadius(16.dp) + .clickable( + actionRunCallback( + actionParametersOf(taskIdKey to task.id) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "✓", + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + ) + } + } + } + } + + private fun getPriorityColor(level: Int): Color { + return when (level) { + 4 -> Color(0xFFDD1C1A) // Urgent - Red + 3 -> Color(0xFFF5A623) // High - Amber + 2 -> Color(0xFF07A0C3) // Medium - Primary + else -> Color(0xFF888888) // Low - Gray + } + } +} + +/** + * Action to complete a task from the widget (Pro only) + */ +class CompleteTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val taskId = parameters[ActionParameters.Key("task_id")] ?: return + + // Send broadcast to app to complete the task + val intent = Intent("com.example.casera.COMPLETE_TASK").apply { + putExtra("task_id", taskId) + setPackage(context.packageName) + } + context.sendBroadcast(intent) + + // Update widget after action + withContext(Dispatchers.Main) { + CaseraLargeWidget().update(context, glanceId) + } + } +} + +/** + * Receiver for the large widget + */ +class CaseraLargeWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CaseraLargeWidget() +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraMediumWidget.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraMediumWidget.kt new file mode 100644 index 0000000..21c43ad --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraMediumWidget.kt @@ -0,0 +1,252 @@ +package com.example.casera.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.serialization.json.Json + +/** + * Medium widget showing a list of upcoming tasks + * Size: 4x2 + */ +class CaseraMediumWidget : GlanceAppWidget() { + + override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + MediumWidgetContent() + } + } + } + + @Composable + private fun MediumWidgetContent() { + val prefs = currentState() + val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0 + val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0 + val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]" + + val tasks = try { + json.decodeFromString>(tasksJson).take(5) + } catch (e: Exception) { + emptyList() + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(Color(0xFFFFF8E7)) // Cream background + .padding(12.dp) + ) { + Column( + modifier = GlanceModifier.fillMaxSize() + ) { + // Header + Row( + modifier = GlanceModifier + .fillMaxWidth() + .clickable(actionRunCallback()), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Casera", + style = TextStyle( + color = ColorProvider(Color(0xFF07A0C3)), + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + // Badge for overdue + if (overdueCount > 0) { + Box( + modifier = GlanceModifier + .background(Color(0xFFDD1C1A)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "$overdueCount overdue", + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 10.sp, + fontWeight = FontWeight.Medium + ) + ) + } + } + } + + Spacer(modifier = GlanceModifier.height(8.dp)) + + // Task list + if (tasks.isEmpty()) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .clickable(actionRunCallback()), + contentAlignment = Alignment.Center + ) { + Text( + text = "No upcoming tasks", + style = TextStyle( + color = ColorProvider(Color(0xFF888888)), + fontSize = 14.sp + ) + ) + } + } else { + LazyColumn( + modifier = GlanceModifier.fillMaxSize() + ) { + items(tasks) { task -> + TaskListItem(task = task) + } + } + } + } + } + } + + @Composable + private fun TaskListItem(task: WidgetTask) { + val taskIdKey = ActionParameters.Key("task_id") + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable( + actionRunCallback( + actionParametersOf(taskIdKey to task.id) + ) + ), + verticalAlignment = Alignment.CenterVertically + ) { + // Priority indicator + Box( + modifier = GlanceModifier + .width(4.dp) + .height(32.dp) + .background(getPriorityColor(task.priorityLevel)) + ) {} + + Spacer(modifier = GlanceModifier.width(8.dp)) + + Column( + modifier = GlanceModifier.fillMaxWidth() + ) { + Text( + text = task.title, + style = TextStyle( + color = ColorProvider(Color(0xFF1A1A1A)), + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1 + ) + Row { + Text( + text = task.residenceName, + style = TextStyle( + color = ColorProvider(Color(0xFF666666)), + fontSize = 11.sp + ), + maxLines = 1 + ) + if (task.dueDate != null) { + Text( + text = " • ${task.dueDate}", + style = TextStyle( + color = ColorProvider( + if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666) + ), + fontSize = 11.sp + ) + ) + } + } + } + } + } + + private fun getPriorityColor(level: Int): Color { + return when (level) { + 4 -> Color(0xFFDD1C1A) // Urgent - Red + 3 -> Color(0xFFF5A623) // High - Amber + 2 -> Color(0xFF07A0C3) // Medium - Primary + else -> Color(0xFF888888) // Low - Gray + } + } +} + +/** + * Action to open a specific task + */ +class OpenTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val taskId = parameters[ActionParameters.Key("task_id")] + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + intent?.let { + it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (taskId != null) { + it.putExtra("navigate_to_task", taskId) + } + context.startActivity(it) + } + } +} + +/** + * Receiver for the medium widget + */ +class CaseraMediumWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CaseraMediumWidget() +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraSmallWidget.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraSmallWidget.kt new file mode 100644 index 0000000..815813b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/CaseraSmallWidget.kt @@ -0,0 +1,171 @@ +package com.example.casera.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.ActionParameters +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey +import com.example.casera.R + +/** + * Small widget showing task count summary + * Size: 2x1 or 2x2 + */ +class CaseraSmallWidget : GlanceAppWidget() { + + override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + SmallWidgetContent() + } + } + } + + @Composable + private fun SmallWidgetContent() { + val prefs = currentState() + val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0 + val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0 + val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0 + + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(Color(0xFFFFF8E7)) // Cream background + .clickable(actionRunCallback()) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = GlanceModifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // App name/logo + Text( + text = "Casera", + style = TextStyle( + color = ColorProvider(Color(0xFF07A0C3)), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + ) + + Spacer(modifier = GlanceModifier.height(8.dp)) + + // Task counts row + Row( + modifier = GlanceModifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Overdue + TaskCountItem( + count = overdueCount, + label = "Overdue", + color = Color(0xFFDD1C1A) // Red + ) + + Spacer(modifier = GlanceModifier.width(16.dp)) + + // Due Soon + TaskCountItem( + count = dueSoonCount, + label = "Due Soon", + color = Color(0xFFF5A623) // Amber + ) + + Spacer(modifier = GlanceModifier.width(16.dp)) + + // In Progress + TaskCountItem( + count = inProgressCount, + label = "Active", + color = Color(0xFF07A0C3) // Primary + ) + } + } + } + } + + @Composable + private fun TaskCountItem(count: Int, label: String, color: Color) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = count.toString(), + style = TextStyle( + color = ColorProvider(color), + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + ) + Text( + text = label, + style = TextStyle( + color = ColorProvider(Color(0xFF666666)), + fontSize = 10.sp + ) + ) + } + } +} + +/** + * Action to open the main app + */ +class OpenAppAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + intent?.let { + it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + context.startActivity(it) + } + } +} + +/** + * Receiver for the small widget + */ +class CaseraSmallWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CaseraSmallWidget() +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetDataRepository.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetDataRepository.kt new file mode 100644 index 0000000..8ef92fb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetDataRepository.kt @@ -0,0 +1,149 @@ +package com.example.casera.widget + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +// DataStore instance +private val Context.widgetDataStore: DataStore by preferencesDataStore(name = "widget_data") + +/** + * Data class representing a task for the widget + */ +@Serializable +data class WidgetTask( + val id: Int, + val title: String, + val residenceName: String, + val dueDate: String?, + val isOverdue: Boolean, + val categoryName: String, + val priorityLevel: Int +) + +/** + * Data class representing widget summary data + */ +@Serializable +data class WidgetSummary( + val overdueCount: Int = 0, + val dueSoonCount: Int = 0, + val inProgressCount: Int = 0, + val totalTasksCount: Int = 0, + val tasks: List = emptyList(), + val lastUpdated: Long = System.currentTimeMillis() +) + +/** + * Repository for managing widget data persistence + */ +class WidgetDataRepository(private val context: Context) { + + private val json = Json { ignoreUnknownKeys = true } + + companion object { + private val OVERDUE_COUNT = intPreferencesKey("overdue_count") + private val DUE_SOON_COUNT = intPreferencesKey("due_soon_count") + private val IN_PROGRESS_COUNT = intPreferencesKey("in_progress_count") + private val TOTAL_TASKS_COUNT = intPreferencesKey("total_tasks_count") + private val TASKS_JSON = stringPreferencesKey("tasks_json") + private val LAST_UPDATED = longPreferencesKey("last_updated") + private val IS_PRO_USER = stringPreferencesKey("is_pro_user") + private val USER_NAME = stringPreferencesKey("user_name") + + @Volatile + private var INSTANCE: WidgetDataRepository? = null + + fun getInstance(context: Context): WidgetDataRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it } + } + } + } + + /** + * Get the widget summary as a Flow + */ + val widgetSummary: Flow = context.widgetDataStore.data.map { preferences -> + val tasksJson = preferences[TASKS_JSON] ?: "[]" + val tasks = try { + json.decodeFromString>(tasksJson) + } catch (e: Exception) { + emptyList() + } + + WidgetSummary( + overdueCount = preferences[OVERDUE_COUNT] ?: 0, + dueSoonCount = preferences[DUE_SOON_COUNT] ?: 0, + inProgressCount = preferences[IN_PROGRESS_COUNT] ?: 0, + totalTasksCount = preferences[TOTAL_TASKS_COUNT] ?: 0, + tasks = tasks, + lastUpdated = preferences[LAST_UPDATED] ?: 0L + ) + } + + /** + * Check if user is a Pro subscriber + */ + val isProUser: Flow = context.widgetDataStore.data.map { preferences -> + preferences[IS_PRO_USER] == "true" + } + + /** + * Get the user's display name + */ + val userName: Flow = context.widgetDataStore.data.map { preferences -> + preferences[USER_NAME] ?: "" + } + + /** + * Update the widget data + */ + suspend fun updateWidgetData(summary: WidgetSummary) { + context.widgetDataStore.edit { preferences -> + preferences[OVERDUE_COUNT] = summary.overdueCount + preferences[DUE_SOON_COUNT] = summary.dueSoonCount + preferences[IN_PROGRESS_COUNT] = summary.inProgressCount + preferences[TOTAL_TASKS_COUNT] = summary.totalTasksCount + preferences[TASKS_JSON] = json.encodeToString(summary.tasks) + preferences[LAST_UPDATED] = System.currentTimeMillis() + } + } + + /** + * Update user subscription status + */ + suspend fun updateProStatus(isPro: Boolean) { + context.widgetDataStore.edit { preferences -> + preferences[IS_PRO_USER] = if (isPro) "true" else "false" + } + } + + /** + * Update user name + */ + suspend fun updateUserName(name: String) { + context.widgetDataStore.edit { preferences -> + preferences[USER_NAME] = name + } + } + + /** + * Clear all widget data (called on logout) + */ + suspend fun clearData() { + context.widgetDataStore.edit { preferences -> + preferences.clear() + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetTaskActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetTaskActionReceiver.kt new file mode 100644 index 0000000..09ef6ec --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetTaskActionReceiver.kt @@ -0,0 +1,56 @@ +package com.example.casera.widget + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.example.casera.data.DataManager +import com.example.casera.models.TaskCompletionCreateRequest +import com.example.casera.network.APILayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * BroadcastReceiver for handling task actions from widgets + */ +class WidgetTaskActionReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + "com.example.casera.COMPLETE_TASK" -> { + val taskId = intent.getIntExtra("task_id", -1) + if (taskId != -1) { + completeTask(context, taskId) + } + } + } + } + + private fun completeTask(context: Context, taskId: Int) { + CoroutineScope(Dispatchers.IO).launch { + try { + // Check if user is authenticated + val token = DataManager.authToken.value + if (token.isNullOrEmpty()) { + return@launch + } + + // Create completion request + val request = TaskCompletionCreateRequest( + taskId = taskId, + notes = "Completed from widget" + ) + + // Complete the task via API + val result = APILayer.createTaskCompletion(request) + + // Update widgets after completion + if (result is com.example.casera.network.ApiResult.Success) { + WidgetUpdateManager.updateAllWidgets(context) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetUpdateManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetUpdateManager.kt new file mode 100644 index 0000000..b5e5d43 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/widget/WidgetUpdateManager.kt @@ -0,0 +1,113 @@ +package com.example.casera.widget + +import android.content.Context +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Manager for updating all widgets with new data + */ +object WidgetUpdateManager { + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Update all Casera widgets with new data + */ + fun updateAllWidgets(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + try { + val repository = WidgetDataRepository.getInstance(context) + val summary = repository.widgetSummary.first() + val isProUser = repository.isProUser.first() + + updateWidgetsWithData(context, summary, isProUser) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + /** + * Update widgets with the provided summary data + */ + suspend fun updateWidgetsWithData( + context: Context, + summary: WidgetSummary, + isProUser: Boolean + ) { + val glanceManager = GlanceAppWidgetManager(context) + + // Update small widgets + val smallWidgetIds = glanceManager.getGlanceIds(CaseraSmallWidget::class.java) + smallWidgetIds.forEach { id -> + updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs -> + prefs.toMutablePreferences().apply { + this[intPreferencesKey("overdue_count")] = summary.overdueCount + this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount + this[intPreferencesKey("in_progress_count")] = summary.inProgressCount + } + } + CaseraSmallWidget().update(context, id) + } + + // Update medium widgets + val mediumWidgetIds = glanceManager.getGlanceIds(CaseraMediumWidget::class.java) + mediumWidgetIds.forEach { id -> + updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs -> + prefs.toMutablePreferences().apply { + this[intPreferencesKey("overdue_count")] = summary.overdueCount + this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount + this[intPreferencesKey("in_progress_count")] = summary.inProgressCount + this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks) + } + } + CaseraMediumWidget().update(context, id) + } + + // Update large widgets + val largeWidgetIds = glanceManager.getGlanceIds(CaseraLargeWidget::class.java) + largeWidgetIds.forEach { id -> + updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs -> + prefs.toMutablePreferences().apply { + this[intPreferencesKey("overdue_count")] = summary.overdueCount + this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount + this[intPreferencesKey("in_progress_count")] = summary.inProgressCount + this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount + this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks) + this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false" + this[longPreferencesKey("last_updated")] = summary.lastUpdated + } + } + CaseraLargeWidget().update(context, id) + } + } + + /** + * Clear all widget data (called on logout) + */ + fun clearAllWidgets(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + try { + val emptyData = WidgetSummary() + updateWidgetsWithData(context, emptyData, false) + + // Also clear the repository + val repository = WidgetDataRepository.getInstance(context) + repository.clearData() + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/composeApp/src/androidMain/res/layout/widget_large_preview.xml b/composeApp/src/androidMain/res/layout/widget_large_preview.xml new file mode 100644 index 0000000..74cc44b --- /dev/null +++ b/composeApp/src/androidMain/res/layout/widget_large_preview.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/widget_loading.xml b/composeApp/src/androidMain/res/layout/widget_loading.xml new file mode 100644 index 0000000..e8b59ef --- /dev/null +++ b/composeApp/src/androidMain/res/layout/widget_loading.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/composeApp/src/androidMain/res/layout/widget_medium_preview.xml b/composeApp/src/androidMain/res/layout/widget_medium_preview.xml new file mode 100644 index 0000000..89ebb6e --- /dev/null +++ b/composeApp/src/androidMain/res/layout/widget_medium_preview.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/widget_small_preview.xml b/composeApp/src/androidMain/res/layout/widget_small_preview.xml new file mode 100644 index 0000000..61d7fa4 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/widget_small_preview.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 037c6e6..ddbc699 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -1,4 +1,14 @@ Casera casera_notifications + + + Casera Summary + Quick task count summary showing overdue, due soon, and active tasks + + Casera Tasks + List of upcoming tasks with quick access to task details + + Casera Dashboard + Full task dashboard with stats and interactive actions (Pro feature) \ No newline at end of file diff --git a/composeApp/src/androidMain/res/xml/casera_large_widget_info.xml b/composeApp/src/androidMain/res/xml/casera_large_widget_info.xml new file mode 100644 index 0000000..715ac7a --- /dev/null +++ b/composeApp/src/androidMain/res/xml/casera_large_widget_info.xml @@ -0,0 +1,17 @@ + + diff --git a/composeApp/src/androidMain/res/xml/casera_medium_widget_info.xml b/composeApp/src/androidMain/res/xml/casera_medium_widget_info.xml new file mode 100644 index 0000000..ffd7e25 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/casera_medium_widget_info.xml @@ -0,0 +1,17 @@ + + diff --git a/composeApp/src/androidMain/res/xml/casera_small_widget_info.xml b/composeApp/src/androidMain/res/xml/casera_small_widget_info.xml new file mode 100644 index 0000000..e247d17 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/casera_small_widget_info.xml @@ -0,0 +1,17 @@ + + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a043cdb..c746dcc 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -72,6 +72,14 @@ Join Property Enter Share Code Join + Join Residence + Would you like to join this shared residence? + Joined Residence + You now have access to %1$s. + Join Failed + Joining... + Shared by: %1$s + Expires: %1$s Pro Feature Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance. Property Members @@ -163,6 +171,35 @@ 2025-01-31 Update Task Failed to Update Task + Failed to cancel task + Failed to restore task + Failed to mark task in progress + Failed to archive task + Failed to unarchive task + IN PROGRESS + Actions + Mark In Progress + Complete Task + Edit Task + Cancel Task + Restore Task + Archive Task + Unarchive Task + N/A + By: %1$s + Cost: $%1$s + View Photos (%1$d) + Add New Task + Property * + Property is required + Browse Task Templates + %1$d common tasks + Category is required + Interval Days (optional) + Override default frequency interval + Due date is required (format: YYYY-MM-DD) + Format: YYYY-MM-DD + Create Task Overdue @@ -184,6 +221,21 @@ In Progress Task cancelled + + Task Templates + Done + Search templates... + Clear + result + results + No Templates Found + Try a different search term + No Templates Available + Templates will appear here once loaded + Expand + Collapse + Add + Task Completions Complete Task @@ -193,6 +245,29 @@ Photos Add Photo Delete this completion record? + Complete Task: %1$s + Select Contractor (optional) + Choose a contractor or leave blank + Expand + None (manual entry) + Loading contractors... + Error loading contractors + Completed By Name (optional) + Enter name if not using contractor above + Actual Cost (optional) + Notes (optional) + Rating: %1$d out of 5 + Add Images + Take Photo + Choose from Library + %1$d image(s) selected + Remove image + Complete + Quality Rating + Photos (%1$d/%2$d) + Camera + Library + Add photos of completed work (optional) Contractors @@ -259,6 +334,32 @@ Import Failed Shared by: %1$s + + Add Contractor + Edit Contractor + Basic Information + Name * + Company + Residence (Optional) + Personal (No Residence) + Only you will see this contractor + All users of %1$s will see this contractor + Contact Information + Phone + Email + Website + Specialties + Address + Street Address + City + State + ZIP Code + Notes + Private Notes + Mark as Favorite + Add + Save + Documents Documents & Warranties @@ -338,6 +439,51 @@ Previous Next + + Edit Warranty + Edit Document + Add Warranty + Add Document + Select Residence + Residence * + Document Type * + Title * + Item Name * + Model Number + Serial Number + Provider/Company * + Provider Contact + Claim Phone + Claim Email + Claim Website + Purchase Date (YYYY-MM-DD) + Warranty Start Date (YYYY-MM-DD) + Warranty End Date (YYYY-MM-DD) * + Description + Category + Select Category + None + Tags + tag1, tag2, tag3 + Notes + Active + Existing Photos (%1$d) + New Photos (%1$d/%2$d) + Photos (%1$d/%2$d) + Camera + Gallery + Image %1$d + Remove image + Update Warranty + Update Document + Please select a residence + Title is required + Item name is required for warranties + Provider is required for warranties + 2024-01-15 + 2025-01-15 + Failed to load residences: %1$s + Profile Edit Profile @@ -453,6 +599,7 @@ Share Import Importing... + Try Again Something went wrong. Please try again. diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index 0b2aec2..add9612 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -81,11 +81,14 @@ fun App( val navController = rememberNavController() // Handle navigation from notification tap + // Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen + // We just need to ensure the user is on MainRoute when a task navigation is requested LaunchedEffect(navigateToTaskId) { if (navigateToTaskId != null && isLoggedIn && isVerified) { - // Navigate to tasks screen (task detail view is handled within the screen) - navController.navigate(TasksRoute) - onClearNavigateToTask() + // Ensure we're on the main screen - MainScreen will handle navigating to the tasks tab + navController.navigate(MainRoute) { + popUpTo(MainRoute) { inclusive = true } + } } } @@ -373,6 +376,8 @@ fun App( // Navigate to first residence or show message if no residences exist // For now, this will be handled by the UI showing "add a property first" }, + navigateToTaskId = navigateToTaskId, + onClearNavigateToTask = onClearNavigateToTask, onNavigateToEditResidence = { residence -> navController.navigate( EditResidenceRoute( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt index 20d7a5a..759f91c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt @@ -186,3 +186,23 @@ data class AppleSignInResponse( val user: User, @SerialName("is_new_user") val isNewUser: Boolean ) + +// Google Sign In Models + +/** + * Google Sign In request matching Go API + */ +@Serializable +data class GoogleSignInRequest( + @SerialName("id_token") val idToken: String +) + +/** + * Google Sign In response matching Go API + */ +@Serializable +data class GoogleSignInResponse( + val token: String, + val user: User, + @SerialName("is_new_user") val isNewUser: Boolean +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index cb7e0d1..4007c11 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -1180,6 +1180,22 @@ object APILayer { return result } + suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult { + val result = authApi.googleSignIn(request) + + // Update DataManager on success + if (result is ApiResult.Success) { + DataManager.setAuthToken(result.data.token) + DataManager.setCurrentUser(result.data.user) + // Initialize lookups after successful Google sign in + initializeLookups() + // Prefetch all data + prefetchAllData() + } + + return result + } + suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult { val result = authApi.updateProfile(token, request) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index 45cffa3..0829e7c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -45,4 +45,16 @@ object ApiConfig { Environment.DEV -> "Dev Server (casera.treytartt.com)" } } + + /** + * Google OAuth Web Client ID + * This is the Web application client ID from Google Cloud Console. + * It should match the GOOGLE_CLIENT_ID configured in the backend. + * + * To get this value: + * 1. Go to Google Cloud Console -> APIs & Services -> Credentials + * 2. Create or use an existing OAuth 2.0 Client ID of type "Web application" + * 3. Copy the Client ID (format: xxx.apps.googleusercontent.com) + */ + const val GOOGLE_WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com" } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt index fd36755..9e97d4b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt @@ -223,4 +223,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + // Google Sign In + suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult { + return try { + val response = client.post("$baseUrl/auth/google-sign-in/") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorBody = try { + response.body>() + } 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") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/HapticFeedback.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/HapticFeedback.kt new file mode 100644 index 0000000..291423d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/HapticFeedback.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/ImageBitmap.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ImageBitmap.kt new file mode 100644 index 0000000..21f61c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ImageBitmap.kt @@ -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? diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt index 6aa6659..075bde6 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt index 7e2bf8a..d762ee4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt @@ -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)) } } ) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt index 0a38450..4090c43 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt @@ -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) + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt index 63d973f..8883824 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt @@ -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)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt index c6b04b3..c84c0b9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt @@ -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)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt index 3a0bae8..a489a92 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt @@ -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 ) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.kt new file mode 100644 index 0000000..9e8733d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.kt @@ -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 +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/dialogs/ThemePickerDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/dialogs/ThemePickerDialog.kt index 7476481..b7181e8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/dialogs/ThemePickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/dialogs/ThemePickerDialog.kt @@ -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) + } ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt index cd126ba..b3f4e13 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt @@ -23,6 +23,7 @@ import com.example.casera.utils.SubscriptionHelper @Composable fun DocumentsTabContent( state: ApiResult>, + filteredDocuments: List = 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 diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt index 8020b2d..5b837ce 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt @@ -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) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt index 3c1a75f..8709c66 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt @@ -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 ) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskKanbanView.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskKanbanView.kt index 10f5e71..5bbf2b8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskKanbanView.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskKanbanView.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt index d850d0c..e19ce1d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt @@ -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(null) } + // Track which column to scroll to (from push notification navigation) + var scrollToColumnIndex by remember { mutableStateOf(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() + } ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt index f572076..d81b8c9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt @@ -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) } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt index 6fc148c..9274c0d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt @@ -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 + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt index e686d6f..2f5f61b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt @@ -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(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() diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt index 5d23e36..015afd4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt @@ -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 ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt index 78f10b3..a74946c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt @@ -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.Idle) val appleSignInState: StateFlow> = _appleSignInState + private val _googleSignInState = MutableStateFlow>(ApiResult.Idle) + val googleSignInState: StateFlow> = _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 diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt index 0c8c283..ead7943 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt @@ -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 diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt index 17cd496..5447c33 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt @@ -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" diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/HapticFeedback.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/HapticFeedback.ios.kt new file mode 100644 index 0000000..775757e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/HapticFeedback.ios.kt @@ -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() } +} diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ImageBitmap.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ImageBitmap.ios.kt new file mode 100644 index 0000000..4fb0d5b --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ImageBitmap.ios.kt @@ -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 + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.ios.kt new file mode 100644 index 0000000..ba32013 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.ios.kt @@ -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 +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/HapticFeedback.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/HapticFeedback.js.kt new file mode 100644 index 0000000..60e1b05 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/HapticFeedback.js.kt @@ -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() } +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/ImageBitmap.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ImageBitmap.js.kt new file mode 100644 index 0000000..4fb0d5b --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ImageBitmap.js.kt @@ -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 + } + } +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.js.kt new file mode 100644 index 0000000..a46bf5e --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.js.kt @@ -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 +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/HapticFeedback.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/HapticFeedback.jvm.kt new file mode 100644 index 0000000..8a9c0b7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/HapticFeedback.jvm.kt @@ -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() } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ImageBitmap.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ImageBitmap.jvm.kt new file mode 100644 index 0000000..4fb0d5b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ImageBitmap.jvm.kt @@ -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 + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.jvm.kt new file mode 100644 index 0000000..33e1f7c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.jvm.kt @@ -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 +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/HapticFeedback.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/HapticFeedback.wasmJs.kt new file mode 100644 index 0000000..1584220 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/HapticFeedback.wasmJs.kt @@ -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() } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ImageBitmap.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ImageBitmap.wasmJs.kt new file mode 100644 index 0000000..4fb0d5b --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ImageBitmap.wasmJs.kt @@ -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 + } + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.wasmJs.kt new file mode 100644 index 0000000..9856a5a --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/ui/components/auth/GoogleSignInButton.wasmJs.kt @@ -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 +}