Add residence sharing via .casera files

- Add SharedResidence model and package type detection for .casera files
- Add generateSharePackage API endpoint integration
- Create ResidenceSharingManager for iOS and Android
- Add share button to residence detail screens (owner only)
- Add residence import handling with confirmation dialogs
- Update Quick Look extensions to show house icon for residence packages
- Route .casera imports by type (contractor vs residence)

🤖 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-06 18:54:46 -06:00
parent 04c3389e4d
commit 83e2cd14a6
27 changed files with 1445 additions and 43 deletions

View File

@@ -35,12 +35,15 @@ import com.example.casera.network.APILayer
import com.example.casera.sharing.ContractorSharingManager
import com.example.casera.data.DataManager
import com.example.casera.data.PersistenceManager
import com.example.casera.models.CaseraPackageType
import com.example.casera.models.detectCaseraPackageType
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 var pendingResidenceImportUri by mutableStateOf<Uri?>(null)
private lateinit var billingManager: BillingManager
override fun onCreate(savedInstanceState: Bundle?) {
@@ -92,6 +95,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
pendingContractorImportUri = pendingContractorImportUri,
onClearContractorImport = {
pendingContractorImportUri = null
},
pendingResidenceImportUri = pendingResidenceImportUri,
onClearResidenceImport = {
pendingResidenceImportUri = null
}
)
}
@@ -242,8 +249,35 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
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
Log.d("MainActivity", "Casera file received: $uri")
// Read file content to detect package type
try {
val inputStream = contentResolver.openInputStream(uri)
if (inputStream != null) {
val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close()
val packageType = detectCaseraPackageType(jsonString)
Log.d("MainActivity", "Detected package type: $packageType")
when (packageType) {
CaseraPackageType.RESIDENCE -> {
Log.d("MainActivity", "Routing to residence import")
pendingResidenceImportUri = uri
}
else -> {
// Default to contractor for backward compatibility
Log.d("MainActivity", "Routing to contractor import")
pendingContractorImportUri = uri
}
}
}
} catch (e: Exception) {
Log.e("MainActivity", "Failed to detect package type, defaulting to contractor", e)
// Default to contractor on error for backward compatibility
pendingContractorImportUri = uri
}
}
}
}

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.JoinResidenceResponse
import com.example.casera.ui.components.ResidenceImportHandler as ResidenceImportHandlerImpl
@Composable
actual fun ResidenceImportHandler(
pendingResidenceImportUri: Any?,
onClearResidenceImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit
) {
// Cast to Android Uri
val uri = pendingResidenceImportUri as? Uri
ResidenceImportHandlerImpl(
pendingImportUri = uri,
onClearImport = onClearResidenceImport,
onImportSuccess = onImportSuccess
)
}

View File

@@ -0,0 +1,39 @@
package com.example.casera.platform
import android.content.Intent
import androidx.compose.runtime.Composable
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.Residence
import com.example.casera.sharing.ResidenceSharingManager
import kotlinx.coroutines.launch
@Composable
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf(ResidenceSharingState()) }
val shareFunction: (Residence) -> Unit = { residence ->
scope.launch {
state = ResidenceSharingState(isLoading = true)
val intent = ResidenceSharingManager.createShareIntent(context, residence)
if (intent != null) {
state = ResidenceSharingState(isLoading = false)
context.startActivity(Intent.createChooser(intent, "Share Residence"))
} else {
state = ResidenceSharingState(
isLoading = false,
error = "Failed to generate share package"
)
}
}
}
return Pair(state, shareFunction)
}

View File

@@ -0,0 +1,122 @@
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.JoinResidenceResponse
import com.example.casera.models.Residence
import com.example.casera.models.SharedResidence
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 residence share package creation and import via .casera files on Android.
* Unlike contractors (which are exported client-side), residence sharing uses
* server-generated share codes.
*/
object ResidenceSharingManager {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
/**
* Creates a share Intent for a residence.
* This first calls the backend to generate a share code, then creates the file.
*
* @param context Android context
* @param residence The residence to share
* @return Share Intent or null if creation failed
*/
suspend fun createShareIntent(context: Context, residence: Residence): Intent? {
return withContext(Dispatchers.IO) {
try {
// Generate share package from backend
val result = APILayer.generateSharePackage(residence.id)
when (result) {
is ApiResult.Success -> {
val sharedResidence = result.data
val jsonString = json.encodeToString(SharedResidence.serializer(), sharedResidence)
// Create safe filename
val safeName = residence.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, "Join my residence: ${residence.name}")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
is ApiResult.Error -> {
null
}
else -> null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
/**
* Imports (joins) a residence from a content URI containing a share code.
*
* @param context Android context
* @param uri The content URI of the .casera file
* @return ApiResult with the JoinResidenceResponse on success, or error on failure
*/
suspend fun importResidence(context: Context, uri: Uri): ApiResult<JoinResidenceResponse> {
return withContext(Dispatchers.IO) {
try {
// Check authentication
if (DataManager.authToken.value == null) {
return@withContext ApiResult.Error("You must be logged in to join a residence", 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 sharedResidence = json.decodeFromString(SharedResidence.serializer(), jsonString)
// Call API with share code
APILayer.joinWithCode(sharedResidence.shareCode)
} catch (e: Exception) {
e.printStackTrace()
ApiResult.Error("Failed to join residence: ${e.message}")
}
}
}
}

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.JoinResidenceResponse
import com.example.casera.models.SharedResidence
import com.example.casera.network.ApiResult
import com.example.casera.sharing.ResidenceSharingManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Represents the current state of the residence import flow.
*/
sealed class ResidenceImportState {
data object Idle : ResidenceImportState()
data class Confirmation(val sharedResidence: SharedResidence) : ResidenceImportState()
data class Importing(val sharedResidence: SharedResidence) : ResidenceImportState()
data class Success(val residenceName: String) : ResidenceImportState()
data class Error(val message: String) : ResidenceImportState()
}
/**
* Android-specific composable that handles the residence 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 join response
*/
@Composable
fun ResidenceImportHandler(
pendingImportUri: Uri?,
onClearImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var importState by remember { mutableStateOf<ResidenceImportState>(ResidenceImportState.Idle) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var importedResponse by remember { mutableStateOf<JoinResidenceResponse?>(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 ResidenceImportState.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 sharedResidence = json.decodeFromString(
SharedResidence.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Confirmation(sharedResidence)
}
} else {
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Error("Could not open file")
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Error("Invalid residence file: ${e.message}")
}
}
}
}
}
// Show appropriate dialog based on state
when (val state = importState) {
is ResidenceImportState.Idle -> {
// No dialog
}
is ResidenceImportState.Confirmation -> {
ResidenceImportConfirmDialog(
sharedResidence = state.sharedResidence,
isImporting = false,
onConfirm = {
importState = ResidenceImportState.Importing(state.sharedResidence)
scope.launch {
pendingUri?.let { uri ->
when (val result = ResidenceSharingManager.importResidence(context, uri)) {
is ApiResult.Success -> {
importedResponse = result.data
importState = ResidenceImportState.Success(result.data.residence.name)
}
is ApiResult.Error -> {
importState = ResidenceImportState.Error(result.message)
}
else -> {
importState = ResidenceImportState.Error("Import failed unexpectedly")
}
}
}
}
},
onDismiss = {
importState = ResidenceImportState.Idle
pendingUri = null
onClearImport()
}
)
}
is ResidenceImportState.Importing -> {
// Show the confirmation dialog with loading state
ResidenceImportConfirmDialog(
sharedResidence = state.sharedResidence,
isImporting = true,
onConfirm = {},
onDismiss = {}
)
}
is ResidenceImportState.Success -> {
ResidenceImportSuccessDialog(
residenceName = state.residenceName,
onDismiss = {
importedResponse?.let { onImportSuccess(it) }
importState = ResidenceImportState.Idle
pendingUri = null
importedResponse = null
onClearImport()
}
)
}
is ResidenceImportState.Error -> {
ResidenceImportErrorDialog(
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 sharedResidence = json.decodeFromString(
SharedResidence.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Confirmation(sharedResidence)
}
}
} catch (e: Exception) {
// Keep showing error
}
}
}
}
},
onDismiss = {
importState = ResidenceImportState.Idle
pendingUri = null
onClearImport()
}
)
}
}
}

View File

@@ -58,6 +58,7 @@ 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 com.example.casera.platform.ResidenceImportHandler
import casera.composeapp.generated.resources.Res
import casera.composeapp.generated.resources.compose_multiplatform
@@ -70,7 +71,9 @@ fun App(
navigateToTaskId: Int? = null,
onClearNavigateToTask: () -> Unit = {},
pendingContractorImportUri: Any? = null,
onClearContractorImport: () -> Unit = {}
onClearContractorImport: () -> Unit = {},
pendingResidenceImportUri: Any? = null,
onClearResidenceImport: () -> Unit = {}
) {
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
var isVerified by remember { mutableStateOf(false) }
@@ -119,6 +122,12 @@ fun App(
onClearContractorImport = onClearContractorImport
)
// Handle residence file imports (Android-specific, no-op on other platforms)
ResidenceImportHandler(
pendingResidenceImportUri = pendingResidenceImportUri,
onClearResidenceImport = onClearResidenceImport
)
if (isCheckingAuth) {
// Show loading screen while checking auth
Surface(

View File

@@ -4,6 +4,17 @@ import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Package type identifiers for .casera files
*/
object CaseraPackageType {
const val CONTRACTOR = "contractor"
const val RESIDENCE = "residence"
}
/**
* Data model for .casera file format used to share contractors between users.
@@ -14,6 +25,9 @@ data class SharedContractor(
/** File format version for future compatibility */
val version: Int = 1,
/** Package type discriminator */
val type: String = CaseraPackageType.CONTRACTOR,
val name: String,
val company: String? = null,
val phone: String? = null,
@@ -46,6 +60,57 @@ data class SharedContractor(
val exportedBy: String? = null
)
/**
* Data model for .casera file format used to share residences between users.
* Contains the share code needed to join the residence.
*/
@Serializable
data class SharedResidence(
/** File format version for future compatibility */
val version: Int = 1,
/** Package type discriminator */
val type: String = CaseraPackageType.RESIDENCE,
/** The share code for joining the residence */
@SerialName("share_code")
val shareCode: String,
/** Name of the residence being shared */
@SerialName("residence_name")
val residenceName: String,
/** Email of the person sharing the residence */
@SerialName("shared_by")
val sharedBy: String? = null,
/** ISO8601 timestamp when the code expires */
@SerialName("expires_at")
val expiresAt: String? = null,
/** ISO8601 timestamp when the package was created */
@SerialName("exported_at")
val exportedAt: String? = null,
/** Username of the person who created the package */
@SerialName("exported_by")
val exportedBy: String? = null
)
/**
* Detect the type of a .casera package from its JSON content.
* Returns null if the type cannot be determined.
*/
fun detectCaseraPackageType(jsonContent: String): String? {
return try {
val json = Json { ignoreUnknownKeys = true }
val jsonObject = json.decodeFromString<JsonObject>(jsonContent)
jsonObject["type"]?.jsonPrimitive?.content ?: CaseraPackageType.CONTRACTOR // Default for backward compatibility
} catch (e: Exception) {
null
}
}
/**
* Convert a full Contractor to SharedContractor for export.
*/
@@ -53,6 +118,7 @@ data class SharedContractor(
fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor {
return SharedContractor(
version = 1,
type = CaseraPackageType.CONTRACTOR,
name = name,
company = company,
phone = phone,

View File

@@ -494,6 +494,11 @@ object APILayer {
return residenceApi.generateShareCode(token, residenceId)
}
suspend fun generateSharePackage(residenceId: Int): ApiResult<SharedResidence> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return residenceApi.generateSharePackage(token, residenceId)
}
suspend fun removeUser(residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return residenceApi.removeUser(token, residenceId, userId)

View File

@@ -126,6 +126,23 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
}
// Share Code Management
suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {

View File

@@ -0,0 +1,20 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.JoinResidenceResponse
/**
* Platform-specific composable that handles residence import flow.
* On Android, shows dialogs to confirm and execute import.
* On other platforms, this is a no-op.
*
* @param pendingResidenceImportUri Platform-specific URI object (e.g., android.net.Uri)
* @param onClearResidenceImport Called when import flow is complete
* @param onImportSuccess Called when a residence is successfully joined
*/
@Composable
expect fun ResidenceImportHandler(
pendingResidenceImportUri: Any?,
onClearResidenceImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit = {}
)

View File

@@ -0,0 +1,24 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Residence
/**
* State holder for residence sharing operation.
*/
data class ResidenceSharingState(
val isLoading: Boolean = false,
val error: String? = null
)
/**
* Returns a pair of state and a function to share a residence.
* The function will:
* 1. Call the backend to generate a share code
* 2. Create a .casera file with the share package
* 3. Open the native share sheet
*
* @return Pair of (state, shareFunction)
*/
@Composable
expect fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit>

View File

@@ -0,0 +1,243 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.Home
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.SharedResidence
/**
* Dialog shown when a user attempts to join a residence from a .casera file.
* Shows residence details and asks for confirmation.
*/
@Composable
fun ResidenceImportConfirmDialog(
sharedResidence: SharedResidence,
isImporting: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = { if (!isImporting) onDismiss() },
icon = {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
},
title = {
Text(
text = "Join Residence",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Would you like to join this shared residence?",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// Residence details
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
text = sharedResidence.residenceName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
sharedResidence.sharedBy?.let { sharedBy ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Shared by: $sharedBy",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
sharedResidence.expiresAt?.let { expiresAt ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Expires: $expiresAt",
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("Joining...")
} else {
Text("Join")
}
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isImporting
) {
Text("Cancel")
}
}
)
}
/**
* Dialog shown after a residence join attempt succeeds.
*/
@Composable
fun ResidenceImportSuccessDialog(
residenceName: 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 = "Joined Residence",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
},
text = {
Text(
text = "You now have access to $residenceName.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
},
confirmButton = {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("OK")
}
}
)
}
/**
* Dialog shown after a residence join attempt fails.
*/
@Composable
fun ResidenceImportErrorDialog(
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 = "Join 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

@@ -36,6 +36,7 @@ import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.data.DataManager
import com.example.casera.util.DateUtils
import com.example.casera.platform.rememberShareResidence
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -79,6 +80,17 @@ fun ResidenceDetailScreen(
// Get current user for ownership checks
val currentUser by DataManager.currentUser.collectAsState()
// Residence sharing state and function
val (shareState, shareResidence) = rememberShareResidence()
var showShareError by remember { mutableStateOf(false) }
// Handle share error
LaunchedEffect(shareState.error) {
if (shareState.error != null) {
showShareError = true
}
}
// Check if tasks are blocked (limit=0) - this hides the FAB
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
// Get current count for checking when adding
@@ -365,6 +377,20 @@ fun ResidenceDetailScreen(
)
}
// Share error dialog
if (showShareError && shareState.error != null) {
AlertDialog(
onDismissRequest = { showShareError = false },
title = { Text(stringResource(Res.string.common_error)) },
text = { Text(shareState.error ?: "Failed to share residence") },
confirmButton = {
TextButton(onClick = { showShareError = false }) {
Text(stringResource(Res.string.common_ok))
}
}
)
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(showReportSnackbar) {
@@ -406,6 +432,23 @@ fun ResidenceDetailScreen(
}
}
// Share button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(
onClick = { shareResidence(residence) },
enabled = !shareState.isLoading
) {
if (shareState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Share, contentDescription = stringResource(Res.string.common_share))
}
}
}
// Manage Users button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(onClick = {

View File

@@ -0,0 +1,17 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.JoinResidenceResponse
/**
* iOS implementation is a no-op - import is handled in Swift layer via ResidenceSharingManager.swift.
* The iOS iOSApp.swift handles file imports directly.
*/
@Composable
actual fun ResidenceImportHandler(
pendingResidenceImportUri: Any?,
onClearResidenceImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> 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 androidx.compose.runtime.remember
import com.example.casera.models.Residence
/**
* iOS implementation is a no-op - sharing is handled in Swift layer via ResidenceSharingManager.swift.
*/
@Composable
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
val state = remember { ResidenceSharingState() }
val noOp: (Residence) -> Unit = { /* No-op on iOS - handled in Swift layer */ }
return Pair(state, noOp)
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.JoinResidenceResponse
/**
* JS implementation is a no-op - file imports are not supported on web.
*/
@Composable
actual fun ResidenceImportHandler(
pendingResidenceImportUri: Any?,
onClearResidenceImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit
) {
// No-op on JS - web doesn't support file imports
}

View File

@@ -0,0 +1,15 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.example.casera.models.Residence
/**
* JS implementation is a no-op - sharing is not supported on web.
*/
@Composable
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
val state = remember { ResidenceSharingState() }
val noOp: (Residence) -> Unit = { /* No-op on JS */ }
return Pair(state, noOp)
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.JoinResidenceResponse
/**
* JVM implementation is a no-op - file imports are not supported on desktop.
*/
@Composable
actual fun ResidenceImportHandler(
pendingResidenceImportUri: Any?,
onClearResidenceImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit
) {
// No-op on JVM - desktop doesn't support file imports
}

View File

@@ -0,0 +1,15 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.example.casera.models.Residence
/**
* JVM implementation is a no-op - sharing is not supported on desktop.
*/
@Composable
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
val state = remember { ResidenceSharingState() }
val noOp: (Residence) -> Unit = { /* No-op on JVM */ }
return Pair(state, noOp)
}

View File

@@ -0,0 +1,16 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.JoinResidenceResponse
/**
* WasmJS implementation is a no-op - file imports are not supported on web.
*/
@Composable
actual fun ResidenceImportHandler(
pendingResidenceImportUri: Any?,
onClearResidenceImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit
) {
// No-op on WasmJS - web doesn't support file imports
}

View File

@@ -0,0 +1,15 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.example.casera.models.Residence
/**
* WasmJS implementation is a no-op - sharing is not supported on web.
*/
@Composable
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
val state = remember { ResidenceSharingState() }
val noOp: (Residence) -> Unit = { /* No-op on WasmJS */ }
return Pair(state, noOp)
}

View File

@@ -3,6 +3,14 @@ import QuickLook
class PreviewViewController: UIViewController, QLPreviewingController {
// MARK: - Types
/// Represents the type of .casera package
private enum PackageType {
case contractor
case residence
}
// MARK: - UI Elements
private let containerView: UIView = {
@@ -89,6 +97,8 @@ class PreviewViewController: UIViewController, QLPreviewingController {
// MARK: - Data
private var contractorData: ContractorPreviewData?
private var residenceData: ResidencePreviewData?
private var currentPackageType: PackageType = .contractor
// MARK: - Lifecycle
@@ -187,20 +197,46 @@ class PreviewViewController: UIViewController, QLPreviewingController {
func preparePreviewOfFile(at url: URL) async throws {
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
// Parse the .casera file
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
self.contractorData = contractor
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
await MainActor.run {
self.updateUI(with: contractor)
// Detect package type first
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String,
typeString == "residence" {
currentPackageType = .residence
let decoder = JSONDecoder()
let residence = try decoder.decode(ResidencePreviewData.self, from: data)
self.residenceData = residence
print("CaseraQLPreview: Parsed residence: \(residence.residenceName)")
await MainActor.run {
self.updateUIForResidence(with: residence)
}
} else {
currentPackageType = .contractor
let decoder = JSONDecoder()
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
self.contractorData = contractor
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
await MainActor.run {
self.updateUIForContractor(with: contractor)
}
}
}
private func updateUI(with contractor: ContractorPreviewData) {
private func updateUIForContractor(with contractor: ContractorPreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config)
titleLabel.text = contractor.name
subtitleLabel.text = "Casera Contractor File"
instructionLabel.text = "Tap the share button below, then select \"Casera\" to import this contractor."
// Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@@ -227,6 +263,28 @@ class PreviewViewController: UIViewController, QLPreviewingController {
addDetailRow(icon: "person", text: "Shared by \(exportedBy)")
}
}
private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
titleLabel.text = residence.residenceName
subtitleLabel.text = "Casera Residence Invite"
instructionLabel.text = "Tap the share button below, then select \"Casera\" to join this residence."
// Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
// Add details
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
}
}
}
// MARK: - Data Model
@@ -262,3 +320,24 @@ struct ContractorPreviewData: Codable {
case exportedBy = "exported_by"
}
}
struct ResidencePreviewData: Codable {
let version: Int
let type: String
let shareCode: String
let residenceName: String
let sharedBy: String?
let expiresAt: String?
let exportedAt: String?
let exportedBy: String?
enum CodingKeys: String, CodingKey {
case version, type
case shareCode = "share_code"
case residenceName = "residence_name"
case sharedBy = "shared_by"
case expiresAt = "expires_at"
case exportedAt = "exported_at"
case exportedBy = "exported_by"
}
}

View File

@@ -10,19 +10,37 @@ import QuickLookThumbnailing
class ThumbnailProvider: QLThumbnailProvider {
/// Represents the type of .casera package
private enum PackageType {
case contractor
case residence
}
override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
let thumbnailSize = request.maximumSize
// Detect package type from file
let packageType = detectPackageType(at: request.fileURL)
handler(QLThumbnailReply(contextSize: thumbnailSize, currentContextDrawing: { () -> Bool in
// Draw background
let backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
backgroundColor.setFill()
UIRectFill(CGRect(origin: .zero, size: thumbnailSize))
// Choose icon based on package type
let iconName: String
switch packageType {
case .contractor:
iconName = "person.crop.rectangle.stack"
case .residence:
iconName = "house.fill"
}
// Draw icon
let config = UIImage.SymbolConfiguration(pointSize: min(thumbnailSize.width, thumbnailSize.height) * 0.5, weight: .regular)
if let icon = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config) {
if let icon = UIImage(systemName: iconName, withConfiguration: config) {
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
let iconSize = tintedIcon.size
let iconOrigin = CGPoint(
@@ -35,4 +53,19 @@ class ThumbnailProvider: QLThumbnailProvider {
return true
}), nil)
}
/// Detects the package type by reading the "type" field from the JSON
private func detectPackageType(at url: URL) -> PackageType {
do {
let data = try Data(contentsOf: url)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String,
typeString == "residence" {
return .residence
}
} catch {
// Default to contractor on error
}
return .contractor
}
}

View File

@@ -17348,10 +17348,6 @@
"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
@@ -17368,9 +17364,16 @@
"comment" : "A title for the registration screen.",
"isCommentAutoGenerated" : true
},
"Join Failed" : {
"comment" : "An alert title displayed when joining a residence fails.",
"isCommentAutoGenerated" : true
},
"Join Residence" : {
"comment" : "A button label that allows a user to join an existing residence.",
"isCommentAutoGenerated" : true
},
"Joined Residence" : {
},
"Joining residence..." : {
"comment" : "A message displayed while waiting for the app to join a residence.",
@@ -24606,6 +24609,10 @@
"comment" : "A label displayed above the share code section of the view.",
"isCommentAutoGenerated" : true
},
"Share Failed" : {
"comment" : "An alert title that appears when sharing a file fails.",
"isCommentAutoGenerated" : true
},
"Share this code with others to give them access to %@" : {
"comment" : "A caption below the share code, explaining that it can be shared with others to give them access to a residence. The argument is the name of the residence.",
"isCommentAutoGenerated" : true
@@ -29683,8 +29690,8 @@
"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.",
"You now have access to %@." : {
"comment" : "A message displayed when a user successfully imports a residence, indicating that they now have access to it. The argument is the name of the residence that was imported.",
"isCommentAutoGenerated" : true
},
"You now have full access to all Pro features!" : {

View File

@@ -32,7 +32,10 @@ struct ResidenceDetailView: View {
@State private var showDeleteConfirmation = false
@State private var isDeleting = false
@State private var showingUpgradePrompt = false
@State private var showShareSheet = false
@State private var shareFileURL: URL?
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var sharingManager = ResidenceSharingManager.shared
@Environment(\.dismiss) private var dismiss
@@ -146,6 +149,22 @@ struct ResidenceDetailView: View {
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
}
.sheet(isPresented: $showShareSheet) {
if let url = shareFileURL {
ShareSheet(activityItems: [url])
}
}
// Share error alert
.alert("Share Failed", isPresented: .init(
get: { sharingManager.errorMessage != nil },
set: { if !$0 { sharingManager.resetState() } }
)) {
Button("OK") {
sharingManager.resetState()
}
} message: {
Text(sharingManager.errorMessage ?? "Failed to create share link.")
}
// MARK: onChange & lifecycle
.onChange(of: viewModel.reportMessage) { message in
@@ -336,15 +355,21 @@ private extension ResidenceDetailView {
}
.disabled(viewModel.isGeneratingReport)
}
// Share Residence button (owner only)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
showManageUsers = true
shareResidence(residence)
} label: {
Image(systemName: "person.2")
if sharingManager.isGeneratingPackage {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(sharingManager.isGeneratingPackage)
}
Button {
// Check LIVE task count before adding
let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0
@@ -357,7 +382,7 @@ private extension ResidenceDetailView {
Image(systemName: "plus")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
showDeleteConfirmation = true
@@ -369,6 +394,15 @@ private extension ResidenceDetailView {
}
}
}
func shareResidence(_ residence: ResidenceResponse) {
Task {
if let url = await sharingManager.createShareableFile(residence: residence) {
shareFileURL = url
showShareSheet = true
}
}
}
}
// MARK: - Data Loading

View File

@@ -0,0 +1,208 @@
import Foundation
import ComposeApp
/// Manages residence share package creation and import via .casera files.
/// For residences, the share code is generated server-side (unlike contractors which are exported client-side).
@MainActor
class ResidenceSharingManager: ObservableObject {
// MARK: - Singleton
static let shared = ResidenceSharingManager()
// MARK: - Published Properties
/// True while generating a share package from the server
@Published var isGeneratingPackage: Bool = false
/// True while importing a residence from a share package
@Published var isImporting: Bool = false
/// Error message if generation or import fails
@Published var errorMessage: String?
/// True after successful import
@Published var importSuccess: Bool = false
/// Name of the imported residence
@Published var importedResidenceName: String?
// MARK: - Private Properties
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
private let jsonDecoder = JSONDecoder()
private init() {}
// MARK: - Export (Share)
/// Creates a shareable .casera file for a residence.
/// This calls the backend to generate a one-time share code, then packages it.
/// - Parameter residence: The residence to share
/// - Returns: URL to the temporary file, or nil if creation failed
func createShareableFile(residence: ResidenceResponse) async -> URL? {
isGeneratingPackage = true
errorMessage = nil
defer { isGeneratingPackage = false }
// Call API to generate share package
let result: Any
do {
result = try await APILayer.shared.generateSharePackage(residenceId: residence.id)
} catch {
errorMessage = "Failed to generate share code: \(error.localizedDescription)"
return nil
}
guard let success = result as? ApiResultSuccess<SharedResidence>,
let sharedResidence = success.data else {
if let error = result as? ApiResultError {
errorMessage = ErrorMessageParser.parse(error.message)
} else {
errorMessage = "Failed to generate share code"
}
return nil
}
// Create Swift-compatible structure for JSON encoding
let exportData = SharedResidenceExport(from: sharedResidence)
guard let jsonData = try? jsonEncoder.encode(exportData) else {
print("ResidenceSharingManager: Failed to encode residence to JSON")
errorMessage = "Failed to create share file"
return nil
}
// Create a safe filename
let safeName = residence.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("ResidenceSharingManager: Failed to write .casera file: \(error)")
errorMessage = "Failed to save share file"
return nil
}
}
// MARK: - Import
/// Imports a residence share from a .casera file URL.
/// This validates the share code with the server and adds the user to the residence.
/// - Parameters:
/// - url: The URL to the .casera file
/// - completion: Called with true on success, false on failure
func importResidence(from url: URL, completion: @escaping (Bool) -> Void) {
isImporting = true
errorMessage = nil
// Verify user is authenticated
guard TokenStorage.shared.getToken() != nil else {
errorMessage = "You must be logged in to join a residence"
isImporting = false
completion(false)
return
}
// Start accessing security-scoped resource if needed
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let data = try Data(contentsOf: url)
let exportData = try jsonDecoder.decode(SharedResidenceExport.self, from: data)
// Use the share code to join the residence
Task {
do {
let result = try await APILayer.shared.joinWithCode(code: exportData.shareCode)
if let success = result as? ApiResultSuccess<JoinResidenceResponse>,
let joinResponse = success.data {
self.importedResidenceName = joinResponse.residence.name
self.importSuccess = true
self.isImporting = false
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isImporting = false
completion(false)
} else {
self.errorMessage = "Unknown error occurred"
self.isImporting = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isImporting = false
completion(false)
}
}
} catch {
errorMessage = "Failed to read residence share file: \(error.localizedDescription)"
isImporting = false
completion(false)
}
}
/// Resets the import state after showing success/error feedback
func resetState() {
errorMessage = nil
importSuccess = false
importedResidenceName = nil
}
}
// MARK: - Swift Codable Structure
/// Swift-native Codable structure for .casera residence share format.
/// This mirrors the Kotlin SharedResidence model for JSON serialization.
struct SharedResidenceExport: Codable {
let version: Int
let type: String
let shareCode: String
let residenceName: String
let sharedBy: String?
let expiresAt: String?
let exportedAt: String?
let exportedBy: String?
enum CodingKeys: String, CodingKey {
case version
case type
case shareCode = "share_code"
case residenceName = "residence_name"
case sharedBy = "shared_by"
case expiresAt = "expires_at"
case exportedAt = "exported_at"
case exportedBy = "exported_by"
}
/// Initialize from Kotlin SharedResidence
init(from sharedResidence: SharedResidence) {
self.version = Int(sharedResidence.version)
self.type = sharedResidence.type
self.shareCode = sharedResidence.shareCode
self.residenceName = sharedResidence.residenceName
self.sharedBy = sharedResidence.sharedBy
self.expiresAt = sharedResidence.expiresAt
self.exportedAt = sharedResidence.exportedAt
self.exportedBy = sharedResidence.exportedBy
}
}

View File

@@ -6,12 +6,20 @@ import WidgetKit
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var themeManager = ThemeManager.shared
@StateObject private var sharingManager = ContractorSharingManager.shared
@StateObject private var contractorSharingManager = ContractorSharingManager.shared
@StateObject private var residenceSharingManager = ResidenceSharingManager.shared
@Environment(\.scenePhase) private var scenePhase
@State private var deepLinkResetToken: String?
@State private var pendingImportURL: URL?
@State private var pendingImportType: CaseraPackageType = .contractor
@State private var showImportConfirmation: Bool = false
/// Type of casera package being imported
enum CaseraPackageType {
case contractor
case residence
}
init() {
// Initialize DataManager with platform-specific managers
// This must be done before any other operations that access DataManager
@@ -37,7 +45,8 @@ struct iOSApp: App {
WindowGroup {
RootView()
.environmentObject(themeManager)
.environmentObject(sharingManager)
.environmentObject(contractorSharingManager)
.environmentObject(residenceSharingManager)
.onOpenURL { url in
handleIncomingURL(url: url)
}
@@ -55,12 +64,19 @@ struct iOSApp: App {
WidgetCenter.shared.reloadAllTimelines()
}
}
// Import confirmation dialog
.alert("Import Contractor", isPresented: $showImportConfirmation) {
// Import confirmation dialog - routes to appropriate handler
.alert(importConfirmationTitle, isPresented: $showImportConfirmation) {
Button("Import") {
if let url = pendingImportURL {
sharingManager.importContractor(from: url) { _ in
pendingImportURL = nil
switch pendingImportType {
case .contractor:
contractorSharingManager.importContractor(from: url) { _ in
pendingImportURL = nil
}
case .residence:
residenceSharingManager.importResidence(from: url) { _ in
pendingImportURL = nil
}
}
}
}
@@ -68,27 +84,66 @@ struct iOSApp: App {
pendingImportURL = nil
}
} message: {
Text("Would you like to import this contractor to your contacts?")
Text(importConfirmationMessage)
}
// Import success dialog
.alert("Contractor Imported", isPresented: $sharingManager.importSuccess) {
// Contractor import success dialog
.alert("Contractor Imported", isPresented: $contractorSharingManager.importSuccess) {
Button("OK") {
sharingManager.resetImportState()
contractorSharingManager.resetImportState()
}
} message: {
Text("\(sharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
Text("\(contractorSharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
}
// Import error dialog
// Contractor import error dialog
.alert("Import Failed", isPresented: .init(
get: { sharingManager.importError != nil },
set: { if !$0 { sharingManager.resetImportState() } }
get: { contractorSharingManager.importError != nil },
set: { if !$0 { contractorSharingManager.resetImportState() } }
)) {
Button("OK") {
sharingManager.resetImportState()
contractorSharingManager.resetImportState()
}
} message: {
Text(sharingManager.importError ?? "An error occurred while importing the contractor.")
Text(contractorSharingManager.importError ?? "An error occurred while importing the contractor.")
}
// Residence import success dialog
.alert("Joined Residence", isPresented: $residenceSharingManager.importSuccess) {
Button("OK") {
residenceSharingManager.resetState()
}
} message: {
Text("You now have access to \(residenceSharingManager.importedResidenceName ?? "the residence").")
}
// Residence import error dialog
.alert("Join Failed", isPresented: .init(
get: { residenceSharingManager.errorMessage != nil && !residenceSharingManager.isImporting },
set: { if !$0 { residenceSharingManager.resetState() } }
)) {
Button("OK") {
residenceSharingManager.resetState()
}
} message: {
Text(residenceSharingManager.errorMessage ?? "An error occurred while joining the residence.")
}
}
}
// MARK: - Import Dialog Helpers
private var importConfirmationTitle: String {
switch pendingImportType {
case .contractor:
return "Import Contractor"
case .residence:
return "Join Residence"
}
}
private var importConfirmationMessage: String {
switch pendingImportType {
case .contractor:
return "Would you like to import this contractor to your contacts?"
case .residence:
return "Would you like to join this shared residence?"
}
}
@@ -100,7 +155,7 @@ struct iOSApp: App {
// Handle .casera file imports
if url.pathExtension.lowercased() == "casera" {
handleContractorImport(url: url)
handleCaseraFileImport(url: url)
return
}
@@ -113,16 +168,43 @@ struct iOSApp: App {
print("Unrecognized URL: \(url)")
}
/// Handles .casera file imports
private func handleContractorImport(url: URL) {
print("Contractor file received: \(url)")
/// Handles .casera file imports - detects type and routes accordingly
private func handleCaseraFileImport(url: URL) {
print("Casera 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"
contractorSharingManager.importError = "You must be logged in to import"
return
}
// Read file and detect type
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let data = try Data(contentsOf: url)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String {
// Route based on type
if typeString == "residence" {
pendingImportType = .residence
} else {
pendingImportType = .contractor
}
} else {
// Default to contractor for backward compatibility (files without type field)
pendingImportType = .contractor
}
} catch {
print("Failed to read casera file: \(error)")
pendingImportType = .contractor
}
// Store URL and show confirmation dialog
pendingImportURL = url
showImportConfirmation = true