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>