Add contractor sharing feature and move settings to navigation bar

Contractor Sharing:
- Add .casera file format for sharing contractors between users
- Create SharedContractor model with JSON serialization
- Implement ContractorSharingManager for iOS (Swift) and Android (Kotlin)
- Register .casera file type in iOS Info.plist and Android manifest
- Add share button to ContractorDetailView (iOS) and ContractorDetailScreen (Android)
- Add import confirmation, success, and error dialogs
- Create expect/actual platform implementations for sharing and import handling

Navigation Changes:
- Remove Profile tab from bottom tab bar (iOS and Android)
- Add settings gear icon to left side of "My Properties" title
- Settings gear opens Profile/Settings screen as sheet (iOS) or navigates (Android)
- Add property button to top right action bar

Bug Fixes:
- Fix ResidenceUsersResponse to match API's flat array response format
- Fix GenerateShareCodeResponse handling to access nested shareCode property
- Update ManageUsersDialog to accept residenceOwnerId parameter

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 22:30:19 -06:00
parent 2965ec4031
commit 859a6679ed
43 changed files with 1848 additions and 148 deletions

View File

@@ -39,6 +39,28 @@
android:scheme="casera"
android:host="reset-password" />
</intent-filter>
<!-- .casera file import -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.casera" />
</intent-filter>
<!-- .casera file import via content:// -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
</activity>
<!-- FileProvider for camera photos -->

View File

@@ -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<String?>(null)
private var navigateToTaskId by mutableStateOf<Int?>(null)
private var pendingContractorImportUri by mutableStateOf<Uri?>(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 {

View File

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

View File

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

View File

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

View File

@@ -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>(ImportState.Idle) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var importedContractor by remember { mutableStateOf<Contractor?>(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()
}
)
}
}
}

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="camera_images" path="/" />
<cache-path name="shared_contractors" path="shared/" />
</paths>

View File

@@ -246,6 +246,13 @@
<string name="contractors_delete">Delete Contractor</string>
<string name="contractors_delete_warning">Are you sure you want to delete this contractor? This action cannot be undone.</string>
<string name="contractors_completed_tasks">%1$d completed tasks</string>
<string name="contractors_share">Share Contractor</string>
<string name="contractors_import_title">Import Contractor</string>
<string name="contractors_import_message">Would you like to import this contractor?</string>
<string name="contractors_import_success">Contractor Imported</string>
<string name="contractors_import_success_message">%1$s has been added to your contacts.</string>
<string name="contractors_import_failed">Import Failed</string>
<string name="contractors_shared_by">Shared by: %1$s</string>
<!-- Documents -->
<string name="documents_title">Documents</string>
@@ -423,6 +430,9 @@
<string name="common_yes">Yes</string>
<string name="common_no">No</string>
<string name="common_ok">OK</string>
<string name="common_share">Share</string>
<string name="common_import">Import</string>
<string name="common_importing">Importing...</string>
<!-- Errors -->
<string name="error_generic">Something went wrong. Please try again.</string>

View File

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

View File

@@ -205,6 +205,11 @@ object DataManager {
private val _lastSyncTime = MutableStateFlow(0L)
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
// ==================== SEEDED DATA ETAG ====================
private val _seededDataETag = MutableStateFlow<String?>(null)
val seededDataETag: StateFlow<String?> = _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"
}

View File

@@ -109,6 +109,21 @@ data class StaticDataResponse(
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>
)
/**
* 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<ResidenceType>,
@SerialName("task_categories") val taskCategories: List<TaskCategory>,
@SerialName("task_priorities") val taskPriorities: List<TaskPriority>,
@SerialName("task_frequencies") val taskFrequencies: List<TaskFrequency>,
@SerialName("task_statuses") val taskStatuses: List<TaskStatus>,
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>,
@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

View File

@@ -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<ResidenceUserResponse>
)
typealias ResidenceUsersResponse = List<ResidenceUserResponse>
/**
* Remove user response

View File

@@ -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<String> = 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<Int>): 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<ContractorSpecialty>): List<Int> {
return specialtyNames.mapNotNull { name ->
availableSpecialties.find { specialty ->
specialty.name.equals(name, ignoreCase = true)
}?.id
}
}

View File

@@ -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<Unit> {
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<Unit> {
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<Unit> {
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<List<TaskTemplate>> {
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<TaskTemplatesGroupedResponse> {
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<List<TaskTemplate>> {
// 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<List<TaskTemplate>> {
// 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<TaskTemplate> {
// 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<List<TaskTemplate>> {
// 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<List<TaskTemplate>> {
// 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<TaskTemplate> {
// 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 ====================

View File

@@ -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<T> {
/**
* Server returned new data (HTTP 200).
* Includes the new ETag for future conditional requests.
*/
data class Success<T>(val data: T, val etag: String?) : ConditionalResult<T>()
/**
* Data has not changed since the provided ETag (HTTP 304).
* Client should continue using cached data.
*/
class NotModified<T> : ConditionalResult<T>()
/**
* Request failed with an error.
*/
data class Error<T>(val message: String, val statusCode: Int? = null) : ConditionalResult<T>()
}
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<SeededDataResponse> {
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")
}
}
}

View File

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

View File

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

View File

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

View File

@@ -24,11 +24,12 @@ fun ManageUsersDialog(
residenceId: Int,
residenceName: String,
isPrimaryOwner: Boolean,
residenceOwnerId: Int,
onDismiss: () -> Unit,
onUserRemoved: () -> Unit = {}
) {
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
var ownerId by remember { mutableStateOf<Int?>(null) }
val ownerId = residenceOwnerId
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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 -> {

View File

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

View File

@@ -232,6 +232,7 @@ fun ResidenceDetailScreen(
residenceId = residence.id,
residenceName = residence.name,
isPrimaryOwner = residence.ownerId == currentUser?.id,
residenceOwnerId = residence.ownerId,
onDismiss = {
showManageUsersDialog = false
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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