Add haptic feedback, rich task completion, and Google Sign-In preparation

- Add platform haptic feedback abstraction (HapticFeedback.kt) with
  implementations for Android, iOS, JVM, JS, and WASM
- Enhance CompleteTaskDialog with interactive 5-star rating, image
  thumbnails, and haptic feedback
- Add ImageBitmap platform abstraction for displaying selected images
- Localize TaskTemplatesBrowserSheet with string resources
- Add Android widgets infrastructure (small, medium, large sizes)
- Add Google Sign-In button components and auth flow preparation
- Update strings.xml with new localization keys for completions,
  templates, and document features
- Integrate haptic feedback into ThemePickerDialog

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-13 00:51:17 -06:00
parent a3e1c338d2
commit 311a30ed2d
61 changed files with 3200 additions and 290 deletions

View File

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

View File

@@ -81,11 +81,14 @@ fun App(
val navController = rememberNavController()
// Handle navigation from notification tap
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
// We just need to ensure the user is on MainRoute when a task navigation is requested
LaunchedEffect(navigateToTaskId) {
if (navigateToTaskId != null && isLoggedIn && isVerified) {
// Navigate to tasks screen (task detail view is handled within the screen)
navController.navigate(TasksRoute)
onClearNavigateToTask()
// Ensure we're on the main screen - MainScreen will handle navigating to the tasks tab
navController.navigate(MainRoute) {
popUpTo(MainRoute) { inclusive = true }
}
}
}
@@ -373,6 +376,8 @@ fun App(
// Navigate to first residence or show message if no residences exist
// For now, this will be handled by the UI showing "add a property first"
},
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = onClearNavigateToTask,
onNavigateToEditResidence = { residence ->
navController.navigate(
EditResidenceRoute(

View File

@@ -186,3 +186,23 @@ data class AppleSignInResponse(
val user: User,
@SerialName("is_new_user") val isNewUser: Boolean
)
// Google Sign In Models
/**
* Google Sign In request matching Go API
*/
@Serializable
data class GoogleSignInRequest(
@SerialName("id_token") val idToken: String
)
/**
* Google Sign In response matching Go API
*/
@Serializable
data class GoogleSignInResponse(
val token: String,
val user: User,
@SerialName("is_new_user") val isNewUser: Boolean
)

View File

@@ -1180,6 +1180,22 @@ object APILayer {
return result
}
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
val result = authApi.googleSignIn(request)
// Update DataManager on success
if (result is ApiResult.Success) {
DataManager.setAuthToken(result.data.token)
DataManager.setCurrentUser(result.data.user)
// Initialize lookups after successful Google sign in
initializeLookups()
// Prefetch all data
prefetchAllData()
}
return result
}
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
val result = authApi.updateProfile(token, request)

View File

@@ -45,4 +45,16 @@ object ApiConfig {
Environment.DEV -> "Dev Server (casera.treytartt.com)"
}
}
/**
* Google OAuth Web Client ID
* This is the Web application client ID from Google Cloud Console.
* It should match the GOOGLE_CLIENT_ID configured in the backend.
*
* To get this value:
* 1. Go to Google Cloud Console -> APIs & Services -> Credentials
* 2. Create or use an existing OAuth 2.0 Client ID of type "Web application"
* 3. Copy the Client ID (format: xxx.apps.googleusercontent.com)
*/
const val GOOGLE_WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com"
}

View File

@@ -223,4 +223,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Google Sign In
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/google-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Google Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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