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

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