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:
@@ -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 -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user