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