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