diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c85f996..c1b4d44 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -39,6 +39,28 @@ android:scheme="casera" android:host="reset-password" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index 63e52c5..b8c885d 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -31,11 +31,14 @@ import com.example.casera.storage.ThemeStorageManager import com.example.casera.ui.theme.ThemeManager import com.example.casera.fcm.FCMManager import com.example.casera.platform.BillingManager +import com.example.casera.network.APILayer +import com.example.casera.sharing.ContractorSharingManager import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { private var deepLinkResetToken by mutableStateOf(null) private var navigateToTaskId by mutableStateOf(null) + private var pendingContractorImportUri by mutableStateOf(null) private lateinit var billingManager: BillingManager override fun onCreate(savedInstanceState: Bundle?) { @@ -55,9 +58,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { // Initialize BillingManager for subscription management billingManager = BillingManager.getInstance(applicationContext) - // Handle deep link and notification navigation from intent + // Handle deep link, notification navigation, and file import from intent handleDeepLink(intent) handleNotificationNavigation(intent) + handleFileImport(intent) // Request notification permission and setup FCM setupFCM() @@ -74,6 +78,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { navigateToTaskId = navigateToTaskId, onClearNavigateToTask = { navigateToTaskId = null + }, + pendingContractorImportUri = pendingContractorImportUri, + onClearContractorImport = { + pendingContractorImportUri = null } ) } @@ -183,10 +191,21 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { } } + override fun onResume() { + super.onResume() + // Check if lookups have changed on server (efficient ETag-based check) + // This ensures app has fresh data when coming back from background + lifecycleScope.launch { + Log.d("MainActivity", "🔄 App resumed, checking for lookup updates...") + APILayer.refreshLookupsIfChanged() + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleDeepLink(intent) handleNotificationNavigation(intent) + handleFileImport(intent) } private fun handleNotificationNavigation(intent: Intent?) { @@ -209,6 +228,16 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { } } + private fun handleFileImport(intent: Intent?) { + if (intent?.action == Intent.ACTION_VIEW) { + val uri = intent.data + if (uri != null && ContractorSharingManager.isCaseraFile(applicationContext, uri)) { + Log.d("MainActivity", "Contractor file received: $uri") + pendingContractorImportUri = uri + } + } + } + override fun newImageLoader(context: PlatformContext): ImageLoader { return ImageLoader.Builder(context) .components { diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorImportHandler.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorImportHandler.android.kt new file mode 100644 index 0000000..74a2698 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorImportHandler.android.kt @@ -0,0 +1,22 @@ +package com.example.casera.platform + +import android.net.Uri +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor +import com.example.casera.ui.components.ContractorImportHandler as ContractorImportHandlerImpl + +@Composable +actual fun ContractorImportHandler( + pendingContractorImportUri: Any?, + onClearContractorImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit +) { + // Cast to Android Uri + val uri = pendingContractorImportUri as? Uri + + ContractorImportHandlerImpl( + pendingImportUri = uri, + onClearImport = onClearContractorImport, + onImportSuccess = onImportSuccess + ) +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt new file mode 100644 index 0000000..0ac2f94 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt @@ -0,0 +1,19 @@ +package com.example.casera.platform + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.example.casera.models.Contractor +import com.example.casera.sharing.ContractorSharingManager + +@Composable +actual fun rememberShareContractor(): (Contractor) -> Unit { + val context = LocalContext.current + + return { contractor: Contractor -> + val intent = ContractorSharingManager.createShareIntent(context, contractor) + if (intent != null) { + context.startActivity(Intent.createChooser(intent, "Share Contractor")) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt new file mode 100644 index 0000000..9376d3c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt @@ -0,0 +1,148 @@ +package com.example.casera.sharing + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import com.example.casera.data.DataManager +import com.example.casera.models.Contractor +import com.example.casera.models.SharedContractor +import com.example.casera.models.resolveSpecialtyIds +import com.example.casera.models.toCreateRequest +import com.example.casera.models.toSharedContractor +import com.example.casera.network.APILayer +import com.example.casera.network.ApiResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.File + +/** + * Manages contractor export and import via .casera files on Android. + */ +object ContractorSharingManager { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** + * Creates a share Intent for a contractor. + * The contractor data is written to a temporary .casera file and shared via FileProvider. + * + * @param context Android context + * @param contractor The contractor to share + * @return Share Intent or null if creation failed + */ + fun createShareIntent(context: Context, contractor: Contractor): Intent? { + return try { + val currentUsername = DataManager.currentUser.value?.username ?: "Unknown" + val sharedContractor = contractor.toSharedContractor(currentUsername) + + val jsonString = json.encodeToString(SharedContractor.serializer(), sharedContractor) + + // Create safe filename + val safeName = contractor.name + .replace(" ", "_") + .replace("/", "-") + .take(50) + val fileName = "${safeName}.casera" + + // Create shared directory + val shareDir = File(context.cacheDir, "shared") + shareDir.mkdirs() + + val file = File(shareDir, fileName) + file.writeText(jsonString) + + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + + Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Contractor: ${contractor.name}") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Imports a contractor from a content URI. + * + * @param context Android context + * @param uri The content URI of the .casera file + * @return ApiResult with the created Contractor on success, or error on failure + */ + suspend fun importContractor(context: Context, uri: Uri): ApiResult { + return withContext(Dispatchers.IO) { + try { + // Check authentication + if (DataManager.authToken.value == null) { + return@withContext ApiResult.Error("You must be logged in to import a contractor", 401) + } + + // Read file content + val inputStream = context.contentResolver.openInputStream(uri) + ?: return@withContext ApiResult.Error("Could not open file") + + val jsonString = inputStream.bufferedReader().use { it.readText() } + inputStream.close() + + // Parse JSON + val sharedContractor = json.decodeFromString(SharedContractor.serializer(), jsonString) + + // Resolve specialty names to IDs + val specialties = DataManager.contractorSpecialties.value + val specialtyIds = sharedContractor.resolveSpecialtyIds(specialties) + + // Create the request + val createRequest = sharedContractor.toCreateRequest(specialtyIds) + + // Call API + APILayer.createContractor(createRequest) + } catch (e: Exception) { + e.printStackTrace() + ApiResult.Error("Failed to import contractor: ${e.message}") + } + } + } + + /** + * Checks if the given URI appears to be a .casera file. + */ + fun isCaseraFile(context: Context, uri: Uri): Boolean { + // Check file extension from URI path + val path = uri.path ?: uri.toString() + if (path.endsWith(".casera", ignoreCase = true)) { + return true + } + + // Try to get display name from content resolver + try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + val name = cursor.getString(nameIndex) + if (name?.endsWith(".casera", ignoreCase = true) == true) { + return true + } + } + } + } + } catch (e: Exception) { + // Ignore errors, fall through to false + } + + return false + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ContractorImportHandler.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ContractorImportHandler.android.kt new file mode 100644 index 0000000..a5c6e75 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ContractorImportHandler.android.kt @@ -0,0 +1,190 @@ +package com.example.casera.ui.components + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.example.casera.models.Contractor +import com.example.casera.models.SharedContractor +import com.example.casera.network.ApiResult +import com.example.casera.sharing.ContractorSharingManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +/** + * Represents the current state of the contractor import flow. + */ +sealed class ImportState { + data object Idle : ImportState() + data class Confirmation(val sharedContractor: SharedContractor) : ImportState() + data class Importing(val sharedContractor: SharedContractor) : ImportState() + data class Success(val contractorName: String) : ImportState() + data class Error(val message: String) : ImportState() +} + +/** + * Android-specific composable that handles the contractor import flow. + * Shows confirmation dialog, performs import, and displays result. + * + * @param pendingImportUri The URI of the .casera file to import (or null if none) + * @param onClearImport Called when import flow is complete and URI should be cleared + * @param onImportSuccess Called when import succeeds, with the imported contractor + */ +@Composable +fun ContractorImportHandler( + pendingImportUri: Uri?, + onClearImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit = {} +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var importState by remember { mutableStateOf(ImportState.Idle) } + var pendingUri by remember { mutableStateOf(null) } + var importedContractor by remember { mutableStateOf(null) } + + val json = remember { + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + + // Parse the .casera file when a new URI is received + LaunchedEffect(pendingImportUri) { + if (pendingImportUri != null && importState is ImportState.Idle) { + pendingUri = pendingImportUri + + withContext(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(pendingImportUri) + if (inputStream != null) { + val jsonString = inputStream.bufferedReader().use { it.readText() } + inputStream.close() + + val sharedContractor = json.decodeFromString( + SharedContractor.serializer(), + jsonString + ) + withContext(Dispatchers.Main) { + importState = ImportState.Confirmation(sharedContractor) + } + } else { + withContext(Dispatchers.Main) { + importState = ImportState.Error("Could not open file") + } + } + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + importState = ImportState.Error("Invalid contractor file: ${e.message}") + } + } + } + } + } + + // Show appropriate dialog based on state + when (val state = importState) { + is ImportState.Idle -> { + // No dialog + } + + is ImportState.Confirmation -> { + ContractorImportConfirmDialog( + sharedContractor = state.sharedContractor, + isImporting = false, + onConfirm = { + importState = ImportState.Importing(state.sharedContractor) + scope.launch { + pendingUri?.let { uri -> + when (val result = ContractorSharingManager.importContractor(context, uri)) { + is ApiResult.Success -> { + importedContractor = result.data + importState = ImportState.Success(result.data.name) + } + is ApiResult.Error -> { + importState = ImportState.Error(result.message) + } + else -> { + importState = ImportState.Error("Import failed unexpectedly") + } + } + } + } + }, + onDismiss = { + importState = ImportState.Idle + pendingUri = null + onClearImport() + } + ) + } + + is ImportState.Importing -> { + // Show the confirmation dialog with loading state + ContractorImportConfirmDialog( + sharedContractor = state.sharedContractor, + isImporting = true, + onConfirm = {}, + onDismiss = {} + ) + } + + is ImportState.Success -> { + ContractorImportSuccessDialog( + contractorName = state.contractorName, + onDismiss = { + importedContractor?.let { onImportSuccess(it) } + importState = ImportState.Idle + pendingUri = null + importedContractor = null + onClearImport() + } + ) + } + + is ImportState.Error -> { + ContractorImportErrorDialog( + errorMessage = state.message, + onRetry = pendingUri?.let { uri -> + { + // Retry by re-parsing the file + scope.launch { + withContext(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(uri) + if (inputStream != null) { + val jsonString = inputStream.bufferedReader().use { it.readText() } + inputStream.close() + val sharedContractor = json.decodeFromString( + SharedContractor.serializer(), + jsonString + ) + withContext(Dispatchers.Main) { + importState = ImportState.Confirmation(sharedContractor) + } + } + } catch (e: Exception) { + // Keep showing error + } + } + } + } + }, + onDismiss = { + importState = ImportState.Idle + pendingUri = null + onClearImport() + } + ) + } + } +} diff --git a/composeApp/src/androidMain/res/xml/file_paths.xml b/composeApp/src/androidMain/res/xml/file_paths.xml index d894287..c8a86b3 100644 --- a/composeApp/src/androidMain/res/xml/file_paths.xml +++ b/composeApp/src/androidMain/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d77182e..8aafcbc 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -246,6 +246,13 @@ Delete Contractor Are you sure you want to delete this contractor? This action cannot be undone. %1$d completed tasks + Share Contractor + Import Contractor + Would you like to import this contractor? + Contractor Imported + %1$s has been added to your contacts. + Import Failed + Shared by: %1$s Documents @@ -423,6 +430,9 @@ Yes No OK + Share + Import + Importing... 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 1b95d41..15e6d3c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -57,6 +57,7 @@ import com.example.casera.network.ApiResult import com.example.casera.network.AuthApi import com.example.casera.data.DataManager import com.example.casera.network.APILayer +import com.example.casera.platform.ContractorImportHandler import casera.composeapp.generated.resources.Res import casera.composeapp.generated.resources.compose_multiplatform @@ -67,7 +68,9 @@ fun App( deepLinkResetToken: String? = null, onClearDeepLinkToken: () -> Unit = {}, navigateToTaskId: Int? = null, - onClearNavigateToTask: () -> Unit = {} + onClearNavigateToTask: () -> Unit = {}, + pendingContractorImportUri: Any? = null, + onClearContractorImport: () -> Unit = {} ) { var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) } var isVerified by remember { mutableStateOf(false) } @@ -110,6 +113,12 @@ fun App( val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } } MyCribTheme(themeColors = currentTheme) { + // Handle contractor file imports (Android-specific, no-op on other platforms) + ContractorImportHandler( + pendingContractorImportUri = pendingContractorImportUri, + onClearContractorImport = onClearContractorImport + ) + if (isCheckingAuth) { // Show loading screen while checking auth Surface( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index 82bea55..afdcf4b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -205,6 +205,11 @@ object DataManager { private val _lastSyncTime = MutableStateFlow(0L) val lastSyncTime: StateFlow = _lastSyncTime.asStateFlow() + // ==================== SEEDED DATA ETAG ==================== + + private val _seededDataETag = MutableStateFlow(null) + val seededDataETag: StateFlow = _seededDataETag.asStateFlow() + // ==================== INITIALIZATION ==================== /** @@ -584,6 +589,34 @@ object DataManager { _lookupsInitialized.value = true } + /** + * Set all lookups from unified seeded data response. + * Also stores the ETag for future conditional requests. + */ + fun setAllLookupsFromSeededData(seededData: SeededDataResponse, etag: String?) { + setResidenceTypes(seededData.residenceTypes) + setTaskFrequencies(seededData.taskFrequencies) + setTaskPriorities(seededData.taskPriorities) + setTaskStatuses(seededData.taskStatuses) + setTaskCategories(seededData.taskCategories) + setContractorSpecialties(seededData.contractorSpecialties) + setTaskTemplatesGrouped(seededData.taskTemplates) + setSeededDataETag(etag) + _lookupsInitialized.value = true + } + + /** + * Set the ETag for seeded data. Used for conditional requests. + */ + fun setSeededDataETag(etag: String?) { + _seededDataETag.value = etag + if (etag != null) { + persistenceManager?.save(KEY_SEEDED_DATA_ETAG, etag) + } else { + persistenceManager?.remove(KEY_SEEDED_DATA_ETAG) + } + } + fun markLookupsInitialized() { _lookupsInitialized.value = true } @@ -632,6 +665,7 @@ object DataManager { _taskTemplates.value = emptyList() _taskTemplatesGrouped.value = null _lookupsInitialized.value = false + _seededDataETag.value = null // Clear cache timestamps residencesCacheTime = 0L @@ -723,6 +757,11 @@ object DataManager { manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data -> _hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false } + + // Load seeded data ETag for conditional requests + manager.load(KEY_SEEDED_DATA_ETAG)?.let { data -> + _seededDataETag.value = data + } } catch (e: Exception) { println("DataManager: Error loading from disk: ${e.message}") } @@ -733,4 +772,5 @@ object DataManager { private const val KEY_CURRENT_USER = "dm_current_user" private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding" + private const val KEY_SEEDED_DATA_ETAG = "dm_seeded_data_etag" } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Lookups.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Lookups.kt index 820ec99..f1416c8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Lookups.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Lookups.kt @@ -109,6 +109,21 @@ data class StaticDataResponse( @SerialName("contractor_specialties") val contractorSpecialties: List ) +/** + * Unified seeded data response - all lookups + task templates in one call + * Supports ETag-based conditional fetching for efficient caching + */ +@Serializable +data class SeededDataResponse( + @SerialName("residence_types") val residenceTypes: List, + @SerialName("task_categories") val taskCategories: List, + @SerialName("task_priorities") val taskPriorities: List, + @SerialName("task_frequencies") val taskFrequencies: List, + @SerialName("task_statuses") val taskStatuses: List, + @SerialName("contractor_specialties") val contractorSpecialties: List, + @SerialName("task_templates") val taskTemplates: TaskTemplatesGroupedResponse +) + // Legacy wrapper responses for backward compatibility // These can be removed once all code is migrated to use arrays directly diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt index 0d888c6..d57daae 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt @@ -232,13 +232,9 @@ data class ResidenceTaskSummary( ) /** - * Residence users response + * Residence users response - API returns a flat list of all users with access */ -@Serializable -data class ResidenceUsersResponse( - val owner: ResidenceUserResponse, - val users: List -) +typealias ResidenceUsersResponse = List /** * Remove user response diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt new file mode 100644 index 0000000..102a823 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt @@ -0,0 +1,107 @@ +package com.example.casera.models + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Data model for .casera file format used to share contractors between users. + * Contains only the data needed to recreate a contractor, without server-specific IDs. + */ +@Serializable +data class SharedContractor( + /** File format version for future compatibility */ + val version: Int = 1, + + val name: String, + val company: String? = null, + val phone: String? = null, + val email: String? = null, + val website: String? = null, + val notes: String? = null, + + @SerialName("street_address") + val streetAddress: String? = null, + val city: String? = null, + @SerialName("state_province") + val stateProvince: String? = null, + @SerialName("postal_code") + val postalCode: String? = null, + + /** Specialty names (not IDs) for cross-account compatibility */ + @SerialName("specialty_names") + val specialtyNames: List = emptyList(), + + val rating: Double? = null, + @SerialName("is_favorite") + val isFavorite: Boolean = false, + + /** ISO8601 timestamp when the contractor was exported */ + @SerialName("exported_at") + val exportedAt: String? = null, + + /** Username of the person who exported the contractor */ + @SerialName("exported_by") + val exportedBy: String? = null +) + +/** + * Convert a full Contractor to SharedContractor for export. + */ +@OptIn(ExperimentalTime::class) +fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor { + return SharedContractor( + version = 1, + name = name, + company = company, + phone = phone, + email = email, + website = website, + notes = notes, + streetAddress = streetAddress, + city = city, + stateProvince = stateProvince, + postalCode = postalCode, + specialtyNames = specialties.map { it.name }, + rating = rating, + isFavorite = isFavorite, + exportedAt = Clock.System.now().toString(), + exportedBy = exportedBy + ) +} + +/** + * Convert SharedContractor to ContractorCreateRequest for import. + * @param specialtyIds The resolved specialty IDs from the importing account's lookup data + */ +fun SharedContractor.toCreateRequest(specialtyIds: List): ContractorCreateRequest { + return ContractorCreateRequest( + name = name, + residenceId = null, // Imported contractors have no residence association + company = company, + phone = phone, + email = email, + website = website, + streetAddress = streetAddress, + city = city, + stateProvince = stateProvince, + postalCode = postalCode, + rating = rating, + isFavorite = isFavorite, + notes = notes, + specialtyIds = specialtyIds.ifEmpty { null } + ) +} + +/** + * Resolve specialty names to IDs using the available specialties in the importing account. + * Case-insensitive matching. + */ +fun SharedContractor.resolveSpecialtyIds(availableSpecialties: List): List { + return specialtyNames.mapNotNull { name -> + availableSpecialties.find { specialty -> + specialty.name.equals(name, ignoreCase = true) + }?.id + } +} 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 4a41708..af58449 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -62,39 +62,47 @@ object APILayer { * Initialize all lookup data. Can be called at app start even without authentication. * Loads all reference data (residence types, task categories, priorities, etc.) into DataManager. * + * Uses ETag-based conditional fetching - if data hasn't changed on server, returns 304 Not Modified + * and uses existing cached data. This is efficient for app foreground/resume scenarios. + * * - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required) * - /subscription/status/ requires auth and is only called if user is authenticated */ suspend fun initializeLookups(): ApiResult { val token = getToken() + val currentETag = DataManager.seededDataETag.value - if (DataManager.lookupsInitialized.value) { - // Lookups already initialized, but refresh subscription status if authenticated - println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...") - if (token != null) { - refreshSubscriptionStatus() - } - return ApiResult.Success(Unit) + // If lookups are already initialized and we have an ETag, do conditional fetch + if (DataManager.lookupsInitialized.value && currentETag != null) { + println("📋 [APILayer] Lookups initialized, checking for updates with ETag...") + return refreshLookupsIfChanged() } try { - // Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required) - println("🔄 Fetching static data (all lookups)...") - val staticDataResult = lookupsApi.getStaticData(token) // token is optional - println("📦 Static data result: $staticDataResult") + // Use seeded data endpoint with ETag support (PUBLIC - no auth required) + println("🔄 Fetching seeded data (all lookups + templates)...") + val seededDataResult = lookupsApi.getSeededData(currentETag, token) + println("📦 Seeded data result: $seededDataResult") - // Update DataManager with all lookups at once - if (staticDataResult is ApiResult.Success) { - DataManager.setAllLookups(staticDataResult.data) - println("✅ All lookups loaded successfully") - } else if (staticDataResult is ApiResult.Error) { - println("❌ Failed to fetch static data: ${staticDataResult.message}") - return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}") + when (seededDataResult) { + is ConditionalResult.Success -> { + println("✅ Seeded data loaded successfully") + DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag) + } + is ConditionalResult.NotModified -> { + println("✅ Seeded data not modified, using cached data") + DataManager.markLookupsInitialized() + } + is ConditionalResult.Error -> { + println("❌ Failed to fetch seeded data: ${seededDataResult.message}") + // Fallback to old static_data endpoint without task templates + return fallbackToLegacyStaticData(token) + } } // Load upgrade triggers (PUBLIC - no auth required) println("🔄 Fetching upgrade triggers...") - val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional + val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) println("📦 Upgrade triggers result: $upgradeTriggersResult") if (upgradeTriggersResult is ApiResult.Success) { @@ -122,20 +130,6 @@ object APILayer { println("⏭️ Skipping subscription status (not authenticated)") } - // Load task templates (PUBLIC - no auth required) - println("🔄 Fetching task templates...") - val templatesResult = taskTemplateApi.getTemplatesGrouped() - println("📦 Task templates result: $templatesResult") - - if (templatesResult is ApiResult.Success) { - println("✅ Updating task templates with ${templatesResult.data.totalCount} templates") - DataManager.setTaskTemplatesGrouped(templatesResult.data) - println("✅ Task templates updated successfully") - } else if (templatesResult is ApiResult.Error) { - println("❌ Failed to fetch task templates: ${templatesResult.message}") - // Non-fatal error - templates are optional for app functionality - } - DataManager.markLookupsInitialized() return ApiResult.Success(Unit) } catch (e: Exception) { @@ -143,6 +137,68 @@ object APILayer { } } + /** + * Refresh lookups only if data has changed on server (using ETag). + * Called when app comes to foreground or resumes. + * Returns quickly with 304 Not Modified if data hasn't changed. + */ + suspend fun refreshLookupsIfChanged(): ApiResult { + val token = getToken() + val currentETag = DataManager.seededDataETag.value + + println("🔄 [APILayer] Checking if lookups have changed (ETag: $currentETag)...") + + val seededDataResult = lookupsApi.getSeededData(currentETag, token) + + when (seededDataResult) { + is ConditionalResult.Success -> { + println("✅ Lookups have changed, updating DataManager") + DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag) + } + is ConditionalResult.NotModified -> { + println("✅ Lookups unchanged (304 Not Modified)") + } + is ConditionalResult.Error -> { + println("❌ Failed to check lookup updates: ${seededDataResult.message}") + // Non-fatal - continue using cached data + } + } + + // Refresh subscription status if authenticated + if (token != null) { + refreshSubscriptionStatus() + } + + return ApiResult.Success(Unit) + } + + /** + * Fallback to legacy static_data endpoint if seeded_data fails. + * Does not include task templates. + */ + private suspend fun fallbackToLegacyStaticData(token: String?): ApiResult { + println("🔄 Falling back to legacy static data endpoint...") + val staticDataResult = lookupsApi.getStaticData(token) + + if (staticDataResult is ApiResult.Success) { + DataManager.setAllLookups(staticDataResult.data) + println("✅ Legacy static data loaded successfully") + + // Try to load task templates separately + val templatesResult = taskTemplateApi.getTemplatesGrouped() + if (templatesResult is ApiResult.Success) { + DataManager.setTaskTemplatesGrouped(templatesResult.data) + } + + DataManager.markLookupsInitialized() + return ApiResult.Success(Unit) + } else if (staticDataResult is ApiResult.Error) { + return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}") + } + + return ApiResult.Error("Unknown error loading lookups") + } + /** * Get residence types from DataManager. If cache is empty, fetch from API. */ @@ -893,95 +949,98 @@ object APILayer { } // ==================== Task Template Operations ==================== + // Task templates are now included in seeded data, so these methods primarily use cache. + // If forceRefresh is needed, use refreshLookupsIfChanged() to get fresh data from server. /** - * Get all task templates from DataManager. If cache is empty, fetch from API. - * Task templates are PUBLIC (no auth required). + * Get all task templates from DataManager. + * Templates are loaded with seeded data, so this uses cache. + * Use forceRefresh to trigger a full seeded data refresh. */ suspend fun getTaskTemplates(forceRefresh: Boolean = false): ApiResult> { - if (!forceRefresh) { - val cached = DataManager.taskTemplates.value - if (cached.isNotEmpty()) { - return ApiResult.Success(cached) - } + if (forceRefresh) { + // Force refresh via seeded data endpoint (includes templates) + refreshLookupsIfChanged() } - val result = taskTemplateApi.getTemplates() - - if (result is ApiResult.Success) { - DataManager.setTaskTemplates(result.data) + val cached = DataManager.taskTemplates.value + if (cached.isNotEmpty()) { + return ApiResult.Success(cached) } - return result + // If still empty, initialize lookups (which includes templates via seeded data) + initializeLookups() + return ApiResult.Success(DataManager.taskTemplates.value) } /** * Get task templates grouped by category. - * Task templates are PUBLIC (no auth required). + * Templates are loaded with seeded data, so this uses cache. */ suspend fun getTaskTemplatesGrouped(forceRefresh: Boolean = false): ApiResult { - if (!forceRefresh) { - val cached = DataManager.taskTemplatesGrouped.value - if (cached != null) { - return ApiResult.Success(cached) - } + if (forceRefresh) { + // Force refresh via seeded data endpoint (includes templates) + refreshLookupsIfChanged() } - val result = taskTemplateApi.getTemplatesGrouped() - - if (result is ApiResult.Success) { - DataManager.setTaskTemplatesGrouped(result.data) - } - - return result - } - - /** - * Search task templates by query string. - * First searches local cache, falls back to API if needed. - */ - suspend fun searchTaskTemplates(query: String): ApiResult> { - // Try local search first if we have templates cached - val cached = DataManager.taskTemplates.value - if (cached.isNotEmpty()) { - val results = DataManager.searchTaskTemplates(query) - return ApiResult.Success(results) - } - - // Fall back to API search - return taskTemplateApi.searchTemplates(query) - } - - /** - * Get templates by category ID. - */ - suspend fun getTemplatesByCategory(categoryId: Int): ApiResult> { - // Try to get from grouped cache first - val grouped = DataManager.taskTemplatesGrouped.value - if (grouped != null) { - val categoryTemplates = grouped.categories - .find { it.categoryId == categoryId }?.templates - if (categoryTemplates != null) { - return ApiResult.Success(categoryTemplates) - } - } - - // Fall back to API - return taskTemplateApi.getTemplatesByCategory(categoryId) - } - - /** - * Get a single task template by ID. - */ - suspend fun getTaskTemplate(id: Int): ApiResult { - // Try to find in cache first - val cached = DataManager.taskTemplates.value.find { it.id == id } + val cached = DataManager.taskTemplatesGrouped.value if (cached != null) { return ApiResult.Success(cached) } - // Fall back to API - return taskTemplateApi.getTemplate(id) + // If still empty, initialize lookups (which includes templates via seeded data) + initializeLookups() + return DataManager.taskTemplatesGrouped.value?.let { + ApiResult.Success(it) + } ?: ApiResult.Error("Failed to load task templates") + } + + /** + * Search task templates by query string. + * Uses local cache only - templates are loaded with seeded data. + */ + suspend fun searchTaskTemplates(query: String): ApiResult> { + // Ensure templates are loaded + if (DataManager.taskTemplates.value.isEmpty()) { + initializeLookups() + } + + val results = DataManager.searchTaskTemplates(query) + return ApiResult.Success(results) + } + + /** + * Get templates by category ID. + * Uses local cache only - templates are loaded with seeded data. + */ + suspend fun getTemplatesByCategory(categoryId: Int): ApiResult> { + // Ensure templates are loaded + if (DataManager.taskTemplatesGrouped.value == null) { + initializeLookups() + } + + val grouped = DataManager.taskTemplatesGrouped.value + val categoryTemplates = grouped?.categories + ?.find { it.categoryId == categoryId }?.templates + ?: emptyList() + + return ApiResult.Success(categoryTemplates) + } + + /** + * Get a single task template by ID. + * Uses local cache only - templates are loaded with seeded data. + */ + suspend fun getTaskTemplate(id: Int): ApiResult { + // Ensure templates are loaded + if (DataManager.taskTemplates.value.isEmpty()) { + initializeLookups() + } + + val cached = DataManager.taskTemplates.value.find { it.id == id } + return cached?.let { + ApiResult.Success(it) + } ?: ApiResult.Error("Task template not found") } // ==================== Auth Operations ==================== diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt index 34f0d80..6a18d3b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt @@ -4,8 +4,32 @@ import com.example.casera.models.* import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* +/** + * Result type for conditional HTTP requests with ETag support. + * Used to efficiently check if data has changed on the server. + */ +sealed class ConditionalResult { + /** + * Server returned new data (HTTP 200). + * Includes the new ETag for future conditional requests. + */ + data class Success(val data: T, val etag: String?) : ConditionalResult() + + /** + * Data has not changed since the provided ETag (HTTP 304). + * Client should continue using cached data. + */ + class NotModified : ConditionalResult() + + /** + * Request failed with an error. + */ + data class Error(val message: String, val statusCode: Int? = null) : ConditionalResult() +} + class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { private val baseUrl = ApiClient.getBaseUrl() @@ -137,4 +161,47 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + /** + * Fetches unified seeded data (all lookups + task templates) with ETag support. + * + * @param currentETag The ETag from a previous response. If provided and data hasn't changed, + * server returns 304 Not Modified. + * @param token Optional auth token (endpoint is public). + * @return ConditionalResult with data and new ETag, NotModified if unchanged, or Error. + */ + suspend fun getSeededData( + currentETag: String? = null, + token: String? = null + ): ConditionalResult { + return try { + val response: HttpResponse = client.get("$baseUrl/static_data/") { + // Token is optional - endpoint is public + token?.let { header("Authorization", "Token $it") } + // Send If-None-Match header for conditional request + currentETag?.let { header("If-None-Match", it) } + } + + when { + response.status == HttpStatusCode.NotModified -> { + // Data hasn't changed since provided ETag + ConditionalResult.NotModified() + } + response.status.isSuccess() -> { + // Data has changed or first request - get new data and ETag + val data: SeededDataResponse = response.body() + val newETag = response.headers["ETag"] + ConditionalResult.Success(data, newETag) + } + else -> { + ConditionalResult.Error( + "Failed to fetch seeded data", + response.status.value + ) + } + } + } catch (e: Exception) { + ConditionalResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/ContractorImportHandler.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ContractorImportHandler.kt new file mode 100644 index 0000000..e4dbd21 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ContractorImportHandler.kt @@ -0,0 +1,20 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +/** + * Platform-specific composable that handles contractor import flow. + * On Android, shows dialogs to confirm and execute import. + * On other platforms, this is a no-op. + * + * @param pendingContractorImportUri Platform-specific URI object (e.g., android.net.Uri) + * @param onClearContractorImport Called when import flow is complete + * @param onImportSuccess Called when a contractor is successfully imported + */ +@Composable +expect fun ContractorImportHandler( + pendingContractorImportUri: Any?, + onClearContractorImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit = {} +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/ContractorSharing.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ContractorSharing.kt new file mode 100644 index 0000000..9b80d92 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ContractorSharing.kt @@ -0,0 +1,11 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +/** + * Returns a function that can be called to share a contractor. + * The returned function will open the native share sheet with a .casera file. + */ +@Composable +expect fun rememberShareContractor(): (Contractor) -> Unit 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 new file mode 100644 index 0000000..63d973f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt @@ -0,0 +1,254 @@ +package com.example.casera.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.models.SharedContractor + +/** + * Dialog shown when a user attempts to import a contractor from a .casera file. + * Shows contractor details and asks for confirmation. + */ +@Composable +fun ContractorImportConfirmDialog( + sharedContractor: SharedContractor, + isImporting: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = { if (!isImporting) onDismiss() }, + icon = { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = "Import Contractor", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Would you like to import this contractor?", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Contractor details + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + Text( + text = sharedContractor.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + sharedContractor.company?.let { company -> + Text( + text = company, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (sharedContractor.specialtyNames.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = sharedContractor.specialtyNames.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + + sharedContractor.exportedBy?.let { exportedBy -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Shared by: $exportedBy", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + confirmButton = { + Button( + onClick = onConfirm, + enabled = !isImporting, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + if (isImporting) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Importing...") + } else { + Text("Import") + } + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isImporting + ) { + Text("Cancel") + } + } + ) +} + +/** + * Dialog shown after a contractor import attempt succeeds. + */ +@Composable +fun ContractorImportSuccessDialog( + contractorName: String, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = "Contractor Imported", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + }, + text = { + Text( + text = "$contractorName has been added to your contacts.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + }, + confirmButton = { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("OK") + } + } + ) +} + +/** + * Dialog shown after a contractor import attempt fails. + */ +@Composable +fun ContractorImportErrorDialog( + errorMessage: String, + onRetry: (() -> Unit)? = null, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text( + text = "Import Failed", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + }, + text = { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + }, + confirmButton = { + if (onRetry != null) { + Button( + onClick = { + onDismiss() + onRetry() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Try Again") + } + } else { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("OK") + } + } + }, + dismissButton = { + if (onRetry != null) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt index a4e184f..698ecd2 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt @@ -24,11 +24,12 @@ fun ManageUsersDialog( residenceId: Int, residenceName: String, isPrimaryOwner: Boolean, + residenceOwnerId: Int, onDismiss: () -> Unit, onUserRemoved: () -> Unit = {} ) { var users by remember { mutableStateOf>(emptyList()) } - var ownerId by remember { mutableStateOf(null) } + val ownerId = residenceOwnerId var shareCode by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } @@ -46,8 +47,7 @@ fun ManageUsersDialog( if (token != null) { when (val result = residenceApi.getResidenceUsers(token, residenceId)) { is ApiResult.Success -> { - users = result.data.users - ownerId = result.data.owner.id + users = result.data isLoading = false } is ApiResult.Error -> { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt index 5d2596f..76d0fb9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt @@ -28,6 +28,7 @@ import com.example.casera.ui.components.HandleErrors import com.example.casera.util.DateUtils import com.example.casera.viewmodel.ContractorViewModel import com.example.casera.network.ApiResult +import com.example.casera.platform.rememberShareContractor import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -45,6 +46,8 @@ fun ContractorDetailScreen( var showEditDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + val shareContractor = rememberShareContractor() + LaunchedEffect(contractorId) { viewModel.loadContractorDetail(contractorId) } @@ -87,6 +90,9 @@ fun ContractorDetailScreen( actions = { when (val state = contractorState) { is ApiResult.Success -> { + IconButton(onClick = { shareContractor(state.data) }) { + Icon(Icons.Default.Share, stringResource(Res.string.common_share)) + } IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) { Icon( if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index 79199ac..e64a36a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -232,6 +232,7 @@ fun ResidenceDetailScreen( residenceId = residence.id, residenceName = residence.name, isPrimaryOwner = residence.ownerId == currentUser?.id, + residenceOwnerId = residence.ownerId, onDismiss = { showManageUsersDialog = false }, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt index cea84a0..c5628f4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt @@ -113,6 +113,15 @@ fun ResidencesScreen( fontWeight = FontWeight.Bold ) }, + navigationIcon = { + IconButton(onClick = onNavigateToProfile) { + Icon( + Icons.Default.Settings, + contentDescription = stringResource(Res.string.profile_title), + tint = MaterialTheme.colorScheme.primary + ) + } + }, actions = { // Only show Join button if not blocked (limit>0) if (!isBlocked.allowed) { @@ -128,11 +137,23 @@ fun ResidencesScreen( Icon(Icons.Default.GroupAdd, contentDescription = stringResource(Res.string.properties_join_title)) } } - IconButton(onClick = onNavigateToProfile) { - Icon(Icons.Default.AccountCircle, contentDescription = stringResource(Res.string.profile_title)) - } - IconButton(onClick = onLogout) { - Icon(Icons.Default.ExitToApp, contentDescription = stringResource(Res.string.home_logout)) + // Add property button + if (!isBlocked.allowed) { + IconButton(onClick = { + val (allowed, triggerKey) = canAddProperty() + if (allowed) { + onAddResidence() + } else { + upgradeTriggerKey = triggerKey + showUpgradePrompt = true + } + }) { + Icon( + Icons.Default.AddCircle, + contentDescription = stringResource(Res.string.properties_add_button), + tint = MaterialTheme.colorScheme.primary + ) + } } }, colors = TopAppBarDefaults.topAppBarColors( diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorImportHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorImportHandler.ios.kt new file mode 100644 index 0000000..1b1a95e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorImportHandler.ios.kt @@ -0,0 +1,17 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +/** + * iOS implementation is a no-op - import is handled in Swift layer via ContractorSharingManager.swift. + * The iOS iOSApp.swift handles file imports directly. + */ +@Composable +actual fun ContractorImportHandler( + pendingContractorImportUri: Any?, + onClearContractorImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit +) { + // No-op on iOS - import handled in Swift layer +} diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt new file mode 100644 index 0000000..31d2e4e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt @@ -0,0 +1,15 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +/** + * iOS implementation is a no-op - sharing is handled in Swift layer via ContractorSharingManager.swift. + * The iOS ContractorDetailView uses the Swift sharing manager directly. + */ +@Composable +actual fun rememberShareContractor(): (Contractor) -> Unit { + return { _: Contractor -> + // No-op on iOS - sharing handled in Swift layer + } +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/ContractorImportHandler.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ContractorImportHandler.js.kt new file mode 100644 index 0000000..e3c4ecb --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ContractorImportHandler.js.kt @@ -0,0 +1,13 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +@Composable +actual fun ContractorImportHandler( + pendingContractorImportUri: Any?, + onClearContractorImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit +) { + // Not implemented for JS/Web +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/ContractorSharing.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ContractorSharing.js.kt new file mode 100644 index 0000000..fa22445 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ContractorSharing.js.kt @@ -0,0 +1,11 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +@Composable +actual fun rememberShareContractor(): (Contractor) -> Unit { + return { _: Contractor -> + // Not implemented for JS/Web + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ContractorImportHandler.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ContractorImportHandler.jvm.kt new file mode 100644 index 0000000..ad0b70b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ContractorImportHandler.jvm.kt @@ -0,0 +1,13 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +@Composable +actual fun ContractorImportHandler( + pendingContractorImportUri: Any?, + onClearContractorImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit +) { + // Not implemented for JVM desktop +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ContractorSharing.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ContractorSharing.jvm.kt new file mode 100644 index 0000000..cadb7aa --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ContractorSharing.jvm.kt @@ -0,0 +1,11 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +@Composable +actual fun rememberShareContractor(): (Contractor) -> Unit { + return { _: Contractor -> + // Not implemented for JVM desktop + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ContractorImportHandler.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ContractorImportHandler.wasmJs.kt new file mode 100644 index 0000000..55275a3 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ContractorImportHandler.wasmJs.kt @@ -0,0 +1,13 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +@Composable +actual fun ContractorImportHandler( + pendingContractorImportUri: Any?, + onClearContractorImport: () -> Unit, + onImportSuccess: (Contractor) -> Unit +) { + // Not implemented for Wasm/Web +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ContractorSharing.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ContractorSharing.wasmJs.kt new file mode 100644 index 0000000..ee4f9ac --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ContractorSharing.wasmJs.kt @@ -0,0 +1,11 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.models.Contractor + +@Composable +actual fun rememberShareContractor(): (Contractor) -> Unit { + return { _: Contractor -> + // Not implemented for Wasm/Web + } +} diff --git a/docs/TOP_50_HOUSEHOLD_TASKS.md b/docs/TOP_50_HOUSEHOLD_TASKS.md new file mode 100644 index 0000000..bdd518c --- /dev/null +++ b/docs/TOP_50_HOUSEHOLD_TASKS.md @@ -0,0 +1,125 @@ +## Weekly Tasks (10) + +| # | Task | Category | Description | +|---|------|----------|-------------| +| 3 | Wipe kitchen counters | Cleaning | Clean countertops and stovetop after cooking | +| 5 | Take out trash | Cleaning | Empty full trash cans to prevent odors and pests | +| 6 | Vacuum floors | Cleaning | Vacuum all carpets and rugs, especially high-traffic areas | +| 7 | Mop hard floors | Cleaning | Mop tile, hardwood, and laminate floors | +| 8 | Clean bathrooms | Cleaning | Scrub toilets, sinks, showers, and mirrors | +| 9 | Change bed linens | Cleaning | Wash and replace sheets, pillowcases, and mattress covers | +| 10 | Do laundry | Cleaning | Wash, dry, fold, and put away clothes | +| 11 | Clean kitchen appliances | Cleaning | Wipe down microwave, dishwasher exterior, coffee maker | +| 12 | Dust surfaces | Cleaning | Dust furniture, shelves, and decorations | +| 13 | Clean out refrigerator | Cleaning | Discard expired food and wipe down shelves | +| 14 | Water indoor plants | Landscaping | Check soil moisture and water as needed | +| 15 | Check/charge security cameras | Safety | Ensure wireless cameras are functioning and charged | + +Check and/or replace water heater anode rod +Test interior water shutoffs +Test gas shutoffs +Test water meter shutoff +Check water meter for leaks +Run drain cleaner +Clean vacuum +Clean microwave +Clean and reverse ceiling fans (fall/spring) +Clean floor registers +Clean toaster +Mop floors 1/2 +Clean bathroom exhaust fans +Clean garbage disposal +Flush HVAC drain lines +Test smoke and carbon monoxide detectors +Clean return vents +Test water heater pressure relief valve +Clean ovens +Clean fridge compressor coils +Clean dishwasher food trap +Mop floors 2/2 +Check fire extinguishers +Replace water filters +Clear HVAC drain lines +Check water meter for leaks +Clean HVAC compressor coils +Test water sensors +Schedule chimney cleaning +Test GFCIs +Schedule HVAC inspection and service +Replace fridge hose +Replace smoke and carbon monoxide detectors +Replace laundry hoses + +--- + +## Monthly Tasks (10) + +| # | Task | Category | Description | +|---|------|----------|-------------| +| 16 | Change HVAC filters | HVAC | Replace air conditioning/furnace filters for efficiency | +| 17 | Test smoke detectors | Safety | Press test button on all smoke and CO detectors | +| 18 | Clean garbage disposal | Appliances | Run ice cubes and lemon peels to clean and deodorize | +| 19 | Inspect for leaks | Plumbing | Check under sinks and around toilets for water damage | +| 20 | Clean vent hood filters | Appliances | Soak and scrub range hood filters to remove grease | +| 21 | Vacuum under furniture | Cleaning | Move furniture to vacuum underneath, especially beds | +| 22 | Clean inside trash cans | Cleaning | Wash and disinfect garbage and recycling bins | +| 23 | Inspect caulking | Plumbing | Check bathroom and kitchen caulk for cracks or mold | +| 24 | Weed garden beds | Landscaping | Remove weeds and prune plants as needed | +| 25 | Check tire pressure | Vehicle | Inspect vehicle tires and refill as needed | + +--- + +## Quarterly Tasks (8) + +| # | Task | Category | Description | +|---|------|----------|-------------| +| 26 | Deep clean oven | Appliances | Clean inside oven; remove baked-on grease and spills | +| 27 | Clean refrigerator coils | Appliances | Vacuum dust from condenser coils for efficiency | +| 28 | Test GFCI outlets | Electrical | Press test/reset buttons on bathroom and kitchen outlets | +| 29 | Flush water heater | Plumbing | Drain sediment from bottom of tank | +| 30 | Clean dishwasher | Appliances | Run empty cycle with cleaner; clean filter and door seals | +| 31 | Inspect fire extinguishers | Safety | Check pressure gauge and ensure accessibility | +| 32 | Clean window tracks | Cleaning | Remove dirt and debris from window and door tracks | +| 33 | Pest control treatment | Safety | Inspect for signs of pests; treat or call professional | + +--- + +## Semi-Annual Tasks (7) + +| # | Task | Category | Description | +|---|------|----------|-------------| +| 34 | Clean gutters | Exterior | Remove leaves and debris; check for proper drainage | +| 35 | HVAC professional service | HVAC | Have system inspected before heating/cooling seasons | +| 36 | Clean dryer vent | Appliances | Remove lint buildup to prevent fires (15,500 fires/year) | +| 37 | Wash windows | Exterior | Clean interior and exterior glass and screens | +| 38 | Inspect roof | Exterior | Look for missing shingles, damage, or debris | +| 39 | Deep clean carpets | Cleaning | Professional carpet cleaning or DIY steam clean | +| 40 | Replace batteries | Safety | Replace smoke/CO detector batteries (if not hardwired) | + +--- + +## Annual Tasks (10) + +| # | Task | Category | Description | +|---|------|----------|-------------| +| 41 | Chimney/fireplace inspection | Safety | Professional inspection before first use of season | +| 42 | Septic tank inspection | Plumbing | Have septic system inspected and pumped if needed | +| 43 | Termite inspection | Safety | Professional inspection for wood-destroying insects | +| 44 | Service garage door | Exterior | Lubricate springs, hinges, and rollers | +| 45 | Inspect weather stripping | Exterior | Check doors and windows; replace worn seals | +| 46 | Winterize outdoor faucets | Plumbing | Shut off water supply and drain lines before freeze | +| 47 | Pressure wash exterior | Exterior | Clean siding, driveway, sidewalks, and deck | +| 48 | Touch up exterior paint | Exterior | Address peeling or cracking paint to prevent moisture damage | +| 49 | Service sprinkler system | Landscaping | Inspect heads, adjust coverage, winterize if needed | +| 50 | Replace washing machine hoses | Appliances | Replace rubber hoses to prevent flooding | + + +## Sources + +- [Care.com - Ultimate Household Chore List](https://www.care.com/c/ultimate-household-chore-list/) +- [Bungalow - Complete Household Chores List](https://bungalow.com/articles/the-complete-household-chores-list) +- [AHIT - Home Maintenance Checklist](https://www.ahit.com/home-inspection-career-guide/home-maintenance-checklist/) +- [Homebuyer.com - Home Maintenance Checklist](https://homebuyer.com/learn/home-maintenance-checklist) +- [Frontdoor - Home Maintenance Checklist](https://www.frontdoor.com/blog/handyman-tips/ultimate-home-maintenance-checklist) +- [Travelers Insurance - Monthly Home Maintenance](https://www.travelers.com/resources/home/maintenance/home-maintenance-checklist-10-easy-things-to-do-monthly) +- [American Family Insurance - Home Maintenance](https://www.amfam.com/resources/articles/at-home/home-maintenance-checklist) diff --git a/iosApp/iosApp/Contractor/ContractorDetailView.swift b/iosApp/iosApp/Contractor/ContractorDetailView.swift index 727958d..9eba6ab 100644 --- a/iosApp/iosApp/Contractor/ContractorDetailView.swift +++ b/iosApp/iosApp/Contractor/ContractorDetailView.swift @@ -11,6 +11,8 @@ struct ContractorDetailView: View { @State private var showingEditSheet = false @State private var showingDeleteAlert = false + @State private var showingShareSheet = false + @State private var shareFileURL: URL? var body: some View { ZStack { @@ -25,6 +27,10 @@ struct ContractorDetailView: View { ToolbarItem(placement: .navigationBarTrailing) { if let contractor = viewModel.selectedContractor { Menu { + Button(action: { shareContractor(contractor) }) { + Label(L10n.Common.share, systemImage: "square.and.arrow.up") + } + Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in viewModel.loadContractorDetail(id: contractorId) }}) { @@ -50,6 +56,11 @@ struct ContractorDetailView: View { } } } + .sheet(isPresented: $showingShareSheet) { + if let url = shareFileURL { + ShareSheet(activityItems: [url]) + } + } .sheet(isPresented: $showingEditSheet) { ContractorFormSheet( contractor: viewModel.selectedContractor, @@ -88,6 +99,13 @@ struct ContractorDetailView: View { } } + private func shareContractor(_ contractor: Contractor) { + if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) { + shareFileURL = url + showingShareSheet = true + } + } + // MARK: - Content State View @ViewBuilder diff --git a/iosApp/iosApp/Contractor/ContractorSharingManager.swift b/iosApp/iosApp/Contractor/ContractorSharingManager.swift new file mode 100644 index 0000000..6d3d56d --- /dev/null +++ b/iosApp/iosApp/Contractor/ContractorSharingManager.swift @@ -0,0 +1,240 @@ +import Foundation +import ComposeApp + +/// Manages contractor export and import via .casera files. +/// Singleton that handles file creation for sharing and parsing for import. +@MainActor +class ContractorSharingManager: ObservableObject { + + // MARK: - Singleton + + static let shared = ContractorSharingManager() + + // MARK: - Published Properties + + @Published var isImporting: Bool = false + @Published var importError: String? + @Published var importSuccess: Bool = false + @Published var importedContractorName: String? + + // MARK: - Private Properties + + private let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + return encoder + }() + + private let jsonDecoder = JSONDecoder() + + private init() {} + + // MARK: - Export + + /// Creates a shareable .casera file for a contractor. + /// - Parameter contractor: The contractor to export + /// - Returns: URL to the temporary file, or nil if creation failed + func createShareableFile(contractor: Contractor) -> URL? { + // Get current username for export metadata + let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown" + + // Convert Contractor to SharedContractor using Kotlin extension + let sharedContractor = contractor.toSharedContractor(exportedBy: currentUsername) + + // Create Swift-compatible structure for JSON encoding + let exportData = SharedContractorExport(from: sharedContractor) + + guard let jsonData = try? jsonEncoder.encode(exportData) else { + print("ContractorSharingManager: Failed to encode contractor to JSON") + return nil + } + + // Create a safe filename + let safeName = contractor.name + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "/", with: "-") + .prefix(50) + let fileName = "\(safeName).casera" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + do { + try jsonData.write(to: tempURL) + return tempURL + } catch { + print("ContractorSharingManager: Failed to write .casera file: \(error)") + return nil + } + } + + // MARK: - Import + + /// Imports a contractor from a .casera file URL. + /// - Parameters: + /// - url: The URL to the .casera file + /// - completion: Called with true on success, false on failure + func importContractor(from url: URL, completion: @escaping (Bool) -> Void) { + isImporting = true + importError = nil + + // Verify user is authenticated + guard TokenStorage.shared.getToken() != nil else { + importError = "You must be logged in to import a contractor" + isImporting = false + completion(false) + return + } + + // Start accessing security-scoped resource if needed (for files from Files app) + let accessing = url.startAccessingSecurityScopedResource() + defer { + if accessing { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let data = try Data(contentsOf: url) + let exportData = try jsonDecoder.decode(SharedContractorExport.self, from: data) + + // Resolve specialty names to IDs + let specialties = DataManagerObservable.shared.contractorSpecialties + let specialtyIds = exportData.resolveSpecialtyIds(availableSpecialties: specialties) + + // Create the request + let createRequest = exportData.toCreateRequest(specialtyIds: specialtyIds) + + // Call API to create contractor + Task { + do { + let result = try await APILayer.shared.createContractor(request: createRequest) + + if let success = result as? ApiResultSuccess, + let contractor = success.data { + self.importedContractorName = contractor.name + self.importSuccess = true + self.isImporting = false + completion(true) + } else if let error = result as? ApiResultError { + self.importError = ErrorMessageParser.parse(error.message) + self.isImporting = false + completion(false) + } else { + self.importError = "Unknown error occurred" + self.isImporting = false + completion(false) + } + } catch { + self.importError = error.localizedDescription + self.isImporting = false + completion(false) + } + } + } catch { + importError = "Failed to read contractor file: \(error.localizedDescription)" + isImporting = false + completion(false) + } + } + + /// Resets the import state after showing success/error feedback + func resetImportState() { + importError = nil + importSuccess = false + importedContractorName = nil + } +} + +// MARK: - Swift Codable Structure + +/// Swift-native Codable structure for .casera file format. +/// This mirrors the Kotlin SharedContractor model for JSON serialization. +struct SharedContractorExport: Codable { + let version: Int + let name: String + let company: String? + let phone: String? + let email: String? + let website: String? + let notes: String? + let streetAddress: String? + let city: String? + let stateProvince: String? + let postalCode: String? + let specialtyNames: [String] + let rating: Double? + let isFavorite: Bool + let exportedAt: String? + let exportedBy: String? + + enum CodingKeys: String, CodingKey { + case version + case name + case company + case phone + case email + case website + case notes + case streetAddress = "street_address" + case city + case stateProvince = "state_province" + case postalCode = "postal_code" + case specialtyNames = "specialty_names" + case rating + case isFavorite = "is_favorite" + case exportedAt = "exported_at" + case exportedBy = "exported_by" + } + + /// Initialize from Kotlin SharedContractor + init(from sharedContractor: SharedContractor) { + self.version = Int(sharedContractor.version) + self.name = sharedContractor.name + self.company = sharedContractor.company + self.phone = sharedContractor.phone + self.email = sharedContractor.email + self.website = sharedContractor.website + self.notes = sharedContractor.notes + self.streetAddress = sharedContractor.streetAddress + self.city = sharedContractor.city + self.stateProvince = sharedContractor.stateProvince + self.postalCode = sharedContractor.postalCode + self.specialtyNames = sharedContractor.specialtyNames + self.rating = sharedContractor.rating?.doubleValue + self.isFavorite = sharedContractor.isFavorite + self.exportedAt = sharedContractor.exportedAt + self.exportedBy = sharedContractor.exportedBy + } + + /// Resolve specialty names to IDs using available specialties + func resolveSpecialtyIds(availableSpecialties: [ContractorSpecialty]) -> [Int32] { + return specialtyNames.compactMap { name in + availableSpecialties.first { specialty in + specialty.name.lowercased() == name.lowercased() + }?.id + } + } + + /// Convert to ContractorCreateRequest for API call + func toCreateRequest(specialtyIds: [Int32]) -> ContractorCreateRequest { + let residenceIdValue: KotlinInt? = nil + let ratingValue: KotlinDouble? = rating.map { KotlinDouble(double: $0) } + let specialtyIdsValue: [KotlinInt]? = specialtyIds.isEmpty ? nil : specialtyIds.map { KotlinInt(int: $0) } + + return ContractorCreateRequest( + name: name, + residenceId: residenceIdValue, + company: company, + phone: phone, + email: email, + website: website, + streetAddress: streetAddress, + city: city, + stateProvince: stateProvince, + postalCode: postalCode, + rating: ratingValue, + isFavorite: isFavorite, + notes: notes, + specialtyIds: specialtyIdsValue + ) + } +} diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift index 143b73b..bceff21 100644 --- a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -36,6 +36,7 @@ struct AccessibilityIdentifiers { static let documentsTab = "TabBar.Documents" static let profileTab = "TabBar.Profile" static let backButton = "Navigation.BackButton" + static let settingsButton = "Navigation.SettingsButton" } // MARK: - Residence diff --git a/iosApp/iosApp/Helpers/L10n.swift b/iosApp/iosApp/Helpers/L10n.swift index 79907ea..b1906d3 100644 --- a/iosApp/iosApp/Helpers/L10n.swift +++ b/iosApp/iosApp/Helpers/L10n.swift @@ -582,6 +582,8 @@ enum L10n { static var yes: String { String(localized: "common_yes") } static var no: String { String(localized: "common_no") } static var ok: String { String(localized: "common_ok") } + static var share: String { String(localized: "common_share") } + static var `import`: String { String(localized: "common_import") } } // MARK: - Errors diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index af18e29..35516e8 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -39,5 +39,43 @@ remote-notification + CFBundleDocumentTypes + + + CFBundleTypeName + Casera Contractor + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + com.casera.contractor + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.casera.contractor + UTTypeDescription + Casera Contractor + UTTypeConformsTo + + public.json + public.data + + UTTypeTagSpecification + + public.filename-extension + + casera + + public.mime-type + application/json + + + diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 65ea748..c0cb403 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -39,6 +39,10 @@ } } }, + "%@ has been added to your contacts." : { + "comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.", + "isCommentAutoGenerated" : true + }, "%@, %@" : { "comment" : "A city and state combination.", "isCommentAutoGenerated" : true, @@ -4747,6 +4751,17 @@ } } }, + "common_import" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import" + } + } + } + }, "common_loading" : { "extractionState" : "manual", "localizations" : { @@ -5072,6 +5087,17 @@ } } }, + "common_share" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + } + } + }, "common_success" : { "extractionState" : "manual", "localizations" : { @@ -5229,6 +5255,9 @@ }, "Continue with Free" : { + }, + "Contractor Imported" : { + }, "Contractors" : { "comment" : "A tab label for the contractors section.", @@ -17315,6 +17344,18 @@ } } }, + "Import" : { + "comment" : "The text on a button that triggers the import action.", + "isCommentAutoGenerated" : true + }, + "Import Contractor" : { + "comment" : "The title of an alert dialog that appears when a user attempts to import a contractor.", + "isCommentAutoGenerated" : true + }, + "Import Failed" : { + "comment" : "A dialog title when importing a contractor fails.", + "isCommentAutoGenerated" : true + }, "In Progress" : { "comment" : "A label displayed next to an image of a play button, indicating that a task is currently in progress.", "isCommentAutoGenerated" : true @@ -17381,6 +17422,10 @@ "comment" : "A message displayed when no task templates match a search query.", "isCommentAutoGenerated" : true }, + "OK" : { + "comment" : "A button that dismisses the success dialog.", + "isCommentAutoGenerated" : true + }, "or" : { }, @@ -17406,10 +17451,6 @@ "comment" : "The title of the \"Pro\" plan in the feature comparison view.", "isCommentAutoGenerated" : true }, - "Profile" : { - "comment" : "A label for the \"Profile\" tab in the main tab view.", - "isCommentAutoGenerated" : true - }, "profile_account" : { "extractionState" : "manual", "localizations" : { @@ -29636,6 +29677,10 @@ "comment" : "The title of the welcome screen in the preview.", "isCommentAutoGenerated" : true }, + "Would you like to import this contractor to your contacts?" : { + "comment" : "A message displayed in an alert when a user imports a contractor.", + "isCommentAutoGenerated" : true + }, "You now have full access to all Pro features!" : { "comment" : "A message displayed to users after successfully upgrading to the Pro version of the app.", "isCommentAutoGenerated" : true diff --git a/iosApp/iosApp/Localizable.xcstrings.backup b/iosApp/iosApp/Localizable.xcstrings.backup new file mode 100644 index 0000000..e69de29 diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 1b5c5bb..9e4531e 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -47,16 +47,6 @@ struct MainTabView: View { } .tag(3) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab) - - NavigationView { - ProfileTabView() - } - .id(refreshID) - .tabItem { - Label("Profile", systemImage: "person.fill") - } - .tag(4) - .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.profileTab) } .tint(Color.appPrimary) .onChange(of: authManager.isAuthenticated) { _ in diff --git a/iosApp/iosApp/Residence/ManageUsersView.swift b/iosApp/iosApp/Residence/ManageUsersView.swift index 56113d3..cdc45a6 100644 --- a/iosApp/iosApp/Residence/ManageUsersView.swift +++ b/iosApp/iosApp/Residence/ManageUsersView.swift @@ -5,10 +5,11 @@ struct ManageUsersView: View { let residenceId: Int32 let residenceName: String let isPrimaryOwner: Bool + let residenceOwnerId: Int32 @Environment(\.dismiss) private var dismiss @State private var users: [ResidenceUserResponse] = [] - @State private var ownerId: Int32? + private var ownerId: Int32 { residenceOwnerId } @State private var shareCode: ShareCodeResponse? @State private var isLoading = true @State private var errorMessage: String? @@ -97,10 +98,9 @@ struct ManageUsersView: View { let result = try await APILayer.shared.getResidenceUsers(residenceId: Int32(Int(residenceId))) await MainActor.run { - if let successResult = result as? ApiResultSuccess, - let responseData = successResult.data as? ResidenceUsersResponse { - self.users = Array(responseData.users) - self.ownerId = Int32(responseData.owner.id) + if let successResult = result as? ApiResultSuccess, + let responseData = successResult.data as? [ResidenceUserResponse] { + self.users = responseData self.isLoading = false } else if let errorResult = result as? ApiResultError { self.errorMessage = ErrorMessageParser.parse(errorResult.message) @@ -148,8 +148,9 @@ struct ManageUsersView: View { let result = try await APILayer.shared.generateShareCode(residenceId: Int32(Int(residenceId))) await MainActor.run { - if let successResult = result as? ApiResultSuccess { - self.shareCode = successResult.data + if let successResult = result as? ApiResultSuccess, + let response = successResult.data { + self.shareCode = response.shareCode self.isGeneratingCode = false } else if let errorResult = result as? ApiResultError { self.errorMessage = ErrorMessageParser.parse(errorResult.message) @@ -195,5 +196,5 @@ struct ManageUsersView: View { } #Preview { - ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true) + ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true, residenceOwnerId: 1) } diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index e5ba2f3..4a84e2f 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -120,7 +120,8 @@ struct ResidenceDetailView: View { ManageUsersView( residenceId: residence.id, residenceName: residence.name, - isPrimaryOwner: isCurrentUserOwner(of: residence) + isPrimaryOwner: isCurrentUserOwner(of: residence), + residenceOwnerId: residence.ownerId ) } } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index bfad321..1a1edd1 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -6,6 +6,7 @@ struct ResidencesListView: View { @State private var showingAddResidence = false @State private var showingJoinResidence = false @State private var showingUpgradePrompt = false + @State private var showingSettings = false @StateObject private var authManager = AuthenticationManager.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @@ -46,6 +47,17 @@ struct ResidencesListView: View { .navigationTitle(L10n.Residences.title) .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + showingSettings = true + }) { + Image(systemName: "gearshape.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton) + } + ToolbarItemGroup(placement: .navigationBarTrailing) { Button(action: { // Check if we should show upgrade prompt before joining @@ -93,6 +105,11 @@ struct ResidencesListView: View { .sheet(isPresented: $showingUpgradePrompt) { UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt) } + .sheet(isPresented: $showingSettings) { + NavigationView { + ProfileTabView() + } + } .onAppear { if authManager.isAuthenticated { viewModel.loadMyResidences() diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 08b42aa..64f9341 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -5,8 +5,11 @@ import ComposeApp struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var themeManager = ThemeManager.shared + @StateObject private var sharingManager = ContractorSharingManager.shared @Environment(\.scenePhase) private var scenePhase @State private var deepLinkResetToken: String? + @State private var pendingImportURL: URL? + @State private var showImportConfirmation: Bool = false init() { // Initialize DataManager with platform-specific managers @@ -33,8 +36,9 @@ struct iOSApp: App { WindowGroup { RootView() .environmentObject(themeManager) + .environmentObject(sharingManager) .onOpenURL { url in - handleDeepLink(url: url) + handleIncomingURL(url: url) } .onChange(of: scenePhase) { newPhase in if newPhase == .active { @@ -42,17 +46,84 @@ struct iOSApp: App { PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded() } } + // Import confirmation dialog + .alert("Import Contractor", isPresented: $showImportConfirmation) { + Button("Import") { + if let url = pendingImportURL { + sharingManager.importContractor(from: url) { _ in + pendingImportURL = nil + } + } + } + Button("Cancel", role: .cancel) { + pendingImportURL = nil + } + } message: { + Text("Would you like to import this contractor to your contacts?") + } + // Import success dialog + .alert("Contractor Imported", isPresented: $sharingManager.importSuccess) { + Button("OK") { + sharingManager.resetImportState() + } + } message: { + Text("\(sharingManager.importedContractorName ?? "Contractor") has been added to your contacts.") + } + // Import error dialog + .alert("Import Failed", isPresented: .init( + get: { sharingManager.importError != nil }, + set: { if !$0 { sharingManager.resetImportState() } } + )) { + Button("OK") { + sharingManager.resetImportState() + } + } message: { + Text(sharingManager.importError ?? "An error occurred while importing the contractor.") + } } } - // MARK: - Deep Link Handling - private func handleDeepLink(url: URL) { - print("Deep link received: \(url)") + // MARK: - URL Handling + /// Handles all incoming URLs - both deep links and file opens + private func handleIncomingURL(url: URL) { + print("URL received: \(url)") + + // Handle .casera file imports + if url.pathExtension.lowercased() == "casera" { + handleContractorImport(url: url) + return + } + + // Handle casera:// deep links + if url.scheme == "casera" { + handleDeepLink(url: url) + return + } + + print("Unrecognized URL: \(url)") + } + + /// Handles .casera file imports + private func handleContractorImport(url: URL) { + print("Contractor file received: \(url)") + + // Check if user is authenticated + guard TokenStorage.shared.getToken() != nil else { + sharingManager.importError = "You must be logged in to import a contractor" + return + } + + // Store URL and show confirmation dialog + pendingImportURL = url + showImportConfirmation = true + } + + /// Handles casera:// deep links + private func handleDeepLink(url: URL) { // Handle casera://reset-password?token=xxx - guard url.scheme == "casera", - url.host == "reset-password" else { - print("Unrecognized deep link scheme or host") + guard url.host == "reset-password" else { + print("Unrecognized deep link host: \(url.host ?? "nil")") return }