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
}