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