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