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.sharing.ContractorSharingManager
|
||||||
import com.example.casera.data.DataManager
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.data.PersistenceManager
|
import com.example.casera.data.PersistenceManager
|
||||||
|
import com.example.casera.models.CaseraPackageType
|
||||||
|
import com.example.casera.models.detectCaseraPackageType
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||||
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||||
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
private var pendingResidenceImportUri by mutableStateOf<Uri?>(null)
|
||||||
private lateinit var billingManager: BillingManager
|
private lateinit var billingManager: BillingManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -92,6 +95,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
pendingContractorImportUri = pendingContractorImportUri,
|
pendingContractorImportUri = pendingContractorImportUri,
|
||||||
onClearContractorImport = {
|
onClearContractorImport = {
|
||||||
pendingContractorImportUri = null
|
pendingContractorImportUri = null
|
||||||
|
},
|
||||||
|
pendingResidenceImportUri = pendingResidenceImportUri,
|
||||||
|
onClearResidenceImport = {
|
||||||
|
pendingResidenceImportUri = null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -242,8 +249,35 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
if (intent?.action == Intent.ACTION_VIEW) {
|
if (intent?.action == Intent.ACTION_VIEW) {
|
||||||
val uri = intent.data
|
val uri = intent.data
|
||||||
if (uri != null && ContractorSharingManager.isCaseraFile(applicationContext, uri)) {
|
if (uri != null && ContractorSharingManager.isCaseraFile(applicationContext, uri)) {
|
||||||
Log.d("MainActivity", "Contractor file received: $uri")
|
Log.d("MainActivity", "Casera file received: $uri")
|
||||||
pendingContractorImportUri = 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.data.DataManager
|
||||||
import com.example.casera.network.APILayer
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.platform.ContractorImportHandler
|
import com.example.casera.platform.ContractorImportHandler
|
||||||
|
import com.example.casera.platform.ResidenceImportHandler
|
||||||
|
|
||||||
import casera.composeapp.generated.resources.Res
|
import casera.composeapp.generated.resources.Res
|
||||||
import casera.composeapp.generated.resources.compose_multiplatform
|
import casera.composeapp.generated.resources.compose_multiplatform
|
||||||
@@ -70,7 +71,9 @@ fun App(
|
|||||||
navigateToTaskId: Int? = null,
|
navigateToTaskId: Int? = null,
|
||||||
onClearNavigateToTask: () -> Unit = {},
|
onClearNavigateToTask: () -> Unit = {},
|
||||||
pendingContractorImportUri: Any? = null,
|
pendingContractorImportUri: Any? = null,
|
||||||
onClearContractorImport: () -> Unit = {}
|
onClearContractorImport: () -> Unit = {},
|
||||||
|
pendingResidenceImportUri: Any? = null,
|
||||||
|
onClearResidenceImport: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||||
var isVerified by remember { mutableStateOf(false) }
|
var isVerified by remember { mutableStateOf(false) }
|
||||||
@@ -119,6 +122,12 @@ fun App(
|
|||||||
onClearContractorImport = onClearContractorImport
|
onClearContractorImport = onClearContractorImport
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Handle residence file imports (Android-specific, no-op on other platforms)
|
||||||
|
ResidenceImportHandler(
|
||||||
|
pendingResidenceImportUri = pendingResidenceImportUri,
|
||||||
|
onClearResidenceImport = onClearResidenceImport
|
||||||
|
)
|
||||||
|
|
||||||
if (isCheckingAuth) {
|
if (isCheckingAuth) {
|
||||||
// Show loading screen while checking auth
|
// Show loading screen while checking auth
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
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.
|
* 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 */
|
/** File format version for future compatibility */
|
||||||
val version: Int = 1,
|
val version: Int = 1,
|
||||||
|
|
||||||
|
/** Package type discriminator */
|
||||||
|
val type: String = CaseraPackageType.CONTRACTOR,
|
||||||
|
|
||||||
val name: String,
|
val name: String,
|
||||||
val company: String? = null,
|
val company: String? = null,
|
||||||
val phone: String? = null,
|
val phone: String? = null,
|
||||||
@@ -46,6 +60,57 @@ data class SharedContractor(
|
|||||||
val exportedBy: String? = null
|
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.
|
* Convert a full Contractor to SharedContractor for export.
|
||||||
*/
|
*/
|
||||||
@@ -53,6 +118,7 @@ data class SharedContractor(
|
|||||||
fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor {
|
fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor {
|
||||||
return SharedContractor(
|
return SharedContractor(
|
||||||
version = 1,
|
version = 1,
|
||||||
|
type = CaseraPackageType.CONTRACTOR,
|
||||||
name = name,
|
name = name,
|
||||||
company = company,
|
company = company,
|
||||||
phone = phone,
|
phone = phone,
|
||||||
|
|||||||
@@ -494,6 +494,11 @@ object APILayer {
|
|||||||
return residenceApi.generateShareCode(token, residenceId)
|
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> {
|
suspend fun removeUser(residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
return residenceApi.removeUser(token, residenceId, userId)
|
return residenceApi.removeUser(token, residenceId, userId)
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Share Code Management
|
// 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> {
|
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
|
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.cache.SubscriptionCache
|
||||||
import com.example.casera.data.DataManager
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.util.DateUtils
|
import com.example.casera.util.DateUtils
|
||||||
|
import com.example.casera.platform.rememberShareResidence
|
||||||
import casera.composeapp.generated.resources.*
|
import casera.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@@ -79,6 +80,17 @@ fun ResidenceDetailScreen(
|
|||||||
// Get current user for ownership checks
|
// Get current user for ownership checks
|
||||||
val currentUser by DataManager.currentUser.collectAsState()
|
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
|
// Check if tasks are blocked (limit=0) - this hides the FAB
|
||||||
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
|
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
|
||||||
// Get current count for checking when adding
|
// 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() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
LaunchedEffect(showReportSnackbar) {
|
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
|
// Manage Users button - only show for primary owners
|
||||||
if (residence.ownerId == currentUser?.id) {
|
if (residence.ownerId == currentUser?.id) {
|
||||||
IconButton(onClick = {
|
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)
|
||||||
|
}
|
||||||
@@ -3,6 +3,14 @@ import QuickLook
|
|||||||
|
|
||||||
class PreviewViewController: UIViewController, QLPreviewingController {
|
class PreviewViewController: UIViewController, QLPreviewingController {
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
/// Represents the type of .casera package
|
||||||
|
private enum PackageType {
|
||||||
|
case contractor
|
||||||
|
case residence
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UI Elements
|
// MARK: - UI Elements
|
||||||
|
|
||||||
private let containerView: UIView = {
|
private let containerView: UIView = {
|
||||||
@@ -89,6 +97,8 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
// MARK: - Data
|
// MARK: - Data
|
||||||
|
|
||||||
private var contractorData: ContractorPreviewData?
|
private var contractorData: ContractorPreviewData?
|
||||||
|
private var residenceData: ResidencePreviewData?
|
||||||
|
private var currentPackageType: PackageType = .contractor
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
@@ -187,20 +197,46 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
|
|
||||||
func preparePreviewOfFile(at url: URL) async throws {
|
func preparePreviewOfFile(at url: URL) async throws {
|
||||||
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
|
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
|
||||||
|
|
||||||
// Parse the .casera file
|
// Parse the .casera file
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
|
|
||||||
self.contractorData = contractor
|
|
||||||
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
|
|
||||||
|
|
||||||
await MainActor.run {
|
// Detect package type first
|
||||||
self.updateUI(with: contractor)
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let typeString = json["type"] as? String,
|
||||||
|
typeString == "residence" {
|
||||||
|
currentPackageType = .residence
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let residence = try decoder.decode(ResidencePreviewData.self, from: data)
|
||||||
|
self.residenceData = residence
|
||||||
|
print("CaseraQLPreview: Parsed residence: \(residence.residenceName)")
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.updateUIForResidence(with: residence)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentPackageType = .contractor
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
|
||||||
|
self.contractorData = contractor
|
||||||
|
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.updateUIForContractor(with: contractor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateUI(with contractor: ContractorPreviewData) {
|
private func updateUIForContractor(with contractor: ContractorPreviewData) {
|
||||||
|
// Update icon
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||||
|
iconImageView.image = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config)
|
||||||
|
|
||||||
titleLabel.text = contractor.name
|
titleLabel.text = contractor.name
|
||||||
|
subtitleLabel.text = "Casera Contractor File"
|
||||||
|
instructionLabel.text = "Tap the share button below, then select \"Casera\" to import this contractor."
|
||||||
|
|
||||||
// Clear existing details
|
// Clear existing details
|
||||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
@@ -227,6 +263,28 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
addDetailRow(icon: "person", text: "Shared by \(exportedBy)")
|
addDetailRow(icon: "person", text: "Shared by \(exportedBy)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateUIForResidence(with residence: ResidencePreviewData) {
|
||||||
|
// Update icon
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||||
|
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||||
|
|
||||||
|
titleLabel.text = residence.residenceName
|
||||||
|
subtitleLabel.text = "Casera Residence Invite"
|
||||||
|
instructionLabel.text = "Tap the share button below, then select \"Casera\" to join this residence."
|
||||||
|
|
||||||
|
// Clear existing details
|
||||||
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
|
// Add details
|
||||||
|
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
||||||
|
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||||
|
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Data Model
|
// MARK: - Data Model
|
||||||
@@ -262,3 +320,24 @@ struct ContractorPreviewData: Codable {
|
|||||||
case exportedBy = "exported_by"
|
case exportedBy = "exported_by"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ResidencePreviewData: Codable {
|
||||||
|
let version: Int
|
||||||
|
let type: String
|
||||||
|
let shareCode: String
|
||||||
|
let residenceName: String
|
||||||
|
let sharedBy: String?
|
||||||
|
let expiresAt: String?
|
||||||
|
let exportedAt: String?
|
||||||
|
let exportedBy: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version, type
|
||||||
|
case shareCode = "share_code"
|
||||||
|
case residenceName = "residence_name"
|
||||||
|
case sharedBy = "shared_by"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case exportedAt = "exported_at"
|
||||||
|
case exportedBy = "exported_by"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,19 +10,37 @@ import QuickLookThumbnailing
|
|||||||
|
|
||||||
class ThumbnailProvider: QLThumbnailProvider {
|
class ThumbnailProvider: QLThumbnailProvider {
|
||||||
|
|
||||||
|
/// Represents the type of .casera package
|
||||||
|
private enum PackageType {
|
||||||
|
case contractor
|
||||||
|
case residence
|
||||||
|
}
|
||||||
|
|
||||||
override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
|
override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
|
||||||
|
|
||||||
let thumbnailSize = request.maximumSize
|
let thumbnailSize = request.maximumSize
|
||||||
|
|
||||||
|
// Detect package type from file
|
||||||
|
let packageType = detectPackageType(at: request.fileURL)
|
||||||
|
|
||||||
handler(QLThumbnailReply(contextSize: thumbnailSize, currentContextDrawing: { () -> Bool in
|
handler(QLThumbnailReply(contextSize: thumbnailSize, currentContextDrawing: { () -> Bool in
|
||||||
// Draw background
|
// Draw background
|
||||||
let backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
let backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||||
backgroundColor.setFill()
|
backgroundColor.setFill()
|
||||||
UIRectFill(CGRect(origin: .zero, size: thumbnailSize))
|
UIRectFill(CGRect(origin: .zero, size: thumbnailSize))
|
||||||
|
|
||||||
|
// Choose icon based on package type
|
||||||
|
let iconName: String
|
||||||
|
switch packageType {
|
||||||
|
case .contractor:
|
||||||
|
iconName = "person.crop.rectangle.stack"
|
||||||
|
case .residence:
|
||||||
|
iconName = "house.fill"
|
||||||
|
}
|
||||||
|
|
||||||
// Draw icon
|
// Draw icon
|
||||||
let config = UIImage.SymbolConfiguration(pointSize: min(thumbnailSize.width, thumbnailSize.height) * 0.5, weight: .regular)
|
let config = UIImage.SymbolConfiguration(pointSize: min(thumbnailSize.width, thumbnailSize.height) * 0.5, weight: .regular)
|
||||||
if let icon = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config) {
|
if let icon = UIImage(systemName: iconName, withConfiguration: config) {
|
||||||
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
|
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
|
||||||
let iconSize = tintedIcon.size
|
let iconSize = tintedIcon.size
|
||||||
let iconOrigin = CGPoint(
|
let iconOrigin = CGPoint(
|
||||||
@@ -35,4 +53,19 @@ class ThumbnailProvider: QLThumbnailProvider {
|
|||||||
return true
|
return true
|
||||||
}), nil)
|
}), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detects the package type by reading the "type" field from the JSON
|
||||||
|
private func detectPackageType(at url: URL) -> PackageType {
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let typeString = json["type"] as? String,
|
||||||
|
typeString == "residence" {
|
||||||
|
return .residence
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Default to contractor on error
|
||||||
|
}
|
||||||
|
return .contractor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17348,10 +17348,6 @@
|
|||||||
"comment" : "The text on a button that triggers the import action.",
|
"comment" : "The text on a button that triggers the import action.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Import Contractor" : {
|
|
||||||
"comment" : "The title of an alert dialog that appears when a user attempts to import a contractor.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Import Failed" : {
|
"Import Failed" : {
|
||||||
"comment" : "A dialog title when importing a contractor fails.",
|
"comment" : "A dialog title when importing a contractor fails.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -17368,9 +17364,16 @@
|
|||||||
"comment" : "A title for the registration screen.",
|
"comment" : "A title for the registration screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Join Failed" : {
|
||||||
|
"comment" : "An alert title displayed when joining a residence fails.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Join Residence" : {
|
"Join Residence" : {
|
||||||
"comment" : "A button label that allows a user to join an existing residence.",
|
"comment" : "A button label that allows a user to join an existing residence.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Joined Residence" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Joining residence..." : {
|
"Joining residence..." : {
|
||||||
"comment" : "A message displayed while waiting for the app to join a residence.",
|
"comment" : "A message displayed while waiting for the app to join a residence.",
|
||||||
@@ -24606,6 +24609,10 @@
|
|||||||
"comment" : "A label displayed above the share code section of the view.",
|
"comment" : "A label displayed above the share code section of the view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Share Failed" : {
|
||||||
|
"comment" : "An alert title that appears when sharing a file fails.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Share this code with others to give them access to %@" : {
|
"Share this code with others to give them access to %@" : {
|
||||||
"comment" : "A caption below the share code, explaining that it can be shared with others to give them access to a residence. The argument is the name of the residence.",
|
"comment" : "A caption below the share code, explaining that it can be shared with others to give them access to a residence. The argument is the name of the residence.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -29683,8 +29690,8 @@
|
|||||||
"comment" : "The title of the welcome screen in the preview.",
|
"comment" : "The title of the welcome screen in the preview.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Would you like to import this contractor to your contacts?" : {
|
"You now have access to %@." : {
|
||||||
"comment" : "A message displayed in an alert when a user imports a contractor.",
|
"comment" : "A message displayed when a user successfully imports a residence, indicating that they now have access to it. The argument is the name of the residence that was imported.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"You now have full access to all Pro features!" : {
|
"You now have full access to all Pro features!" : {
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ struct ResidenceDetailView: View {
|
|||||||
@State private var showDeleteConfirmation = false
|
@State private var showDeleteConfirmation = false
|
||||||
@State private var isDeleting = false
|
@State private var isDeleting = false
|
||||||
@State private var showingUpgradePrompt = false
|
@State private var showingUpgradePrompt = false
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
@State private var shareFileURL: URL?
|
||||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||||
|
@StateObject private var sharingManager = ResidenceSharingManager.shared
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@@ -146,6 +149,22 @@ struct ResidenceDetailView: View {
|
|||||||
.sheet(isPresented: $showingUpgradePrompt) {
|
.sheet(isPresented: $showingUpgradePrompt) {
|
||||||
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
|
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
if let url = shareFileURL {
|
||||||
|
ShareSheet(activityItems: [url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Share error alert
|
||||||
|
.alert("Share Failed", isPresented: .init(
|
||||||
|
get: { sharingManager.errorMessage != nil },
|
||||||
|
set: { if !$0 { sharingManager.resetState() } }
|
||||||
|
)) {
|
||||||
|
Button("OK") {
|
||||||
|
sharingManager.resetState()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(sharingManager.errorMessage ?? "Failed to create share link.")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: onChange & lifecycle
|
// MARK: onChange & lifecycle
|
||||||
.onChange(of: viewModel.reportMessage) { message in
|
.onChange(of: viewModel.reportMessage) { message in
|
||||||
@@ -337,12 +356,18 @@ private extension ResidenceDetailView {
|
|||||||
.disabled(viewModel.isGeneratingReport)
|
.disabled(viewModel.isGeneratingReport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Share Residence button (owner only)
|
||||||
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
|
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
|
||||||
Button {
|
Button {
|
||||||
showManageUsers = true
|
shareResidence(residence)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "person.2")
|
if sharingManager.isGeneratingPackage {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(sharingManager.isGeneratingPackage)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -369,6 +394,15 @@ private extension ResidenceDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shareResidence(_ residence: ResidenceResponse) {
|
||||||
|
Task {
|
||||||
|
if let url = await sharingManager.createShareableFile(residence: residence) {
|
||||||
|
shareFileURL = url
|
||||||
|
showShareSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Data Loading
|
// MARK: - Data Loading
|
||||||
|
|||||||
208
iosApp/iosApp/Residence/ResidenceSharingManager.swift
Normal file
208
iosApp/iosApp/Residence/ResidenceSharingManager.swift
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
/// Manages residence share package creation and import via .casera files.
|
||||||
|
/// For residences, the share code is generated server-side (unlike contractors which are exported client-side).
|
||||||
|
@MainActor
|
||||||
|
class ResidenceSharingManager: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = ResidenceSharingManager()
|
||||||
|
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
/// True while generating a share package from the server
|
||||||
|
@Published var isGeneratingPackage: Bool = false
|
||||||
|
|
||||||
|
/// True while importing a residence from a share package
|
||||||
|
@Published var isImporting: Bool = false
|
||||||
|
|
||||||
|
/// Error message if generation or import fails
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
/// True after successful import
|
||||||
|
@Published var importSuccess: Bool = false
|
||||||
|
|
||||||
|
/// Name of the imported residence
|
||||||
|
@Published var importedResidenceName: String?
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
|
||||||
|
private let jsonEncoder: JSONEncoder = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Export (Share)
|
||||||
|
|
||||||
|
/// Creates a shareable .casera file for a residence.
|
||||||
|
/// This calls the backend to generate a one-time share code, then packages it.
|
||||||
|
/// - Parameter residence: The residence to share
|
||||||
|
/// - Returns: URL to the temporary file, or nil if creation failed
|
||||||
|
func createShareableFile(residence: ResidenceResponse) async -> URL? {
|
||||||
|
isGeneratingPackage = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
defer { isGeneratingPackage = false }
|
||||||
|
|
||||||
|
// Call API to generate share package
|
||||||
|
let result: Any
|
||||||
|
do {
|
||||||
|
result = try await APILayer.shared.generateSharePackage(residenceId: residence.id)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to generate share code: \(error.localizedDescription)"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let success = result as? ApiResultSuccess<SharedResidence>,
|
||||||
|
let sharedResidence = success.data else {
|
||||||
|
if let error = result as? ApiResultError {
|
||||||
|
errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
} else {
|
||||||
|
errorMessage = "Failed to generate share code"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Swift-compatible structure for JSON encoding
|
||||||
|
let exportData = SharedResidenceExport(from: sharedResidence)
|
||||||
|
|
||||||
|
guard let jsonData = try? jsonEncoder.encode(exportData) else {
|
||||||
|
print("ResidenceSharingManager: Failed to encode residence to JSON")
|
||||||
|
errorMessage = "Failed to create share file"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a safe filename
|
||||||
|
let safeName = residence.name
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: "/", with: "-")
|
||||||
|
.prefix(50)
|
||||||
|
let fileName = "\(safeName).casera"
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try jsonData.write(to: tempURL)
|
||||||
|
return tempURL
|
||||||
|
} catch {
|
||||||
|
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
|
||||||
|
errorMessage = "Failed to save share file"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import
|
||||||
|
|
||||||
|
/// Imports a residence share from a .casera file URL.
|
||||||
|
/// This validates the share code with the server and adds the user to the residence.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: The URL to the .casera file
|
||||||
|
/// - completion: Called with true on success, false on failure
|
||||||
|
func importResidence(from url: URL, completion: @escaping (Bool) -> Void) {
|
||||||
|
isImporting = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
// Verify user is authenticated
|
||||||
|
guard TokenStorage.shared.getToken() != nil else {
|
||||||
|
errorMessage = "You must be logged in to join a residence"
|
||||||
|
isImporting = false
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start accessing security-scoped resource if needed
|
||||||
|
let accessing = url.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessing {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let exportData = try jsonDecoder.decode(SharedResidenceExport.self, from: data)
|
||||||
|
|
||||||
|
// Use the share code to join the residence
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.joinWithCode(code: exportData.shareCode)
|
||||||
|
|
||||||
|
if let success = result as? ApiResultSuccess<JoinResidenceResponse>,
|
||||||
|
let joinResponse = success.data {
|
||||||
|
self.importedResidenceName = joinResponse.residence.name
|
||||||
|
self.importSuccess = true
|
||||||
|
self.isImporting = false
|
||||||
|
completion(true)
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isImporting = false
|
||||||
|
completion(false)
|
||||||
|
} else {
|
||||||
|
self.errorMessage = "Unknown error occurred"
|
||||||
|
self.isImporting = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isImporting = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to read residence share file: \(error.localizedDescription)"
|
||||||
|
isImporting = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the import state after showing success/error feedback
|
||||||
|
func resetState() {
|
||||||
|
errorMessage = nil
|
||||||
|
importSuccess = false
|
||||||
|
importedResidenceName = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swift Codable Structure
|
||||||
|
|
||||||
|
/// Swift-native Codable structure for .casera residence share format.
|
||||||
|
/// This mirrors the Kotlin SharedResidence model for JSON serialization.
|
||||||
|
struct SharedResidenceExport: Codable {
|
||||||
|
let version: Int
|
||||||
|
let type: String
|
||||||
|
let shareCode: String
|
||||||
|
let residenceName: String
|
||||||
|
let sharedBy: String?
|
||||||
|
let expiresAt: String?
|
||||||
|
let exportedAt: String?
|
||||||
|
let exportedBy: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version
|
||||||
|
case type
|
||||||
|
case shareCode = "share_code"
|
||||||
|
case residenceName = "residence_name"
|
||||||
|
case sharedBy = "shared_by"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case exportedAt = "exported_at"
|
||||||
|
case exportedBy = "exported_by"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize from Kotlin SharedResidence
|
||||||
|
init(from sharedResidence: SharedResidence) {
|
||||||
|
self.version = Int(sharedResidence.version)
|
||||||
|
self.type = sharedResidence.type
|
||||||
|
self.shareCode = sharedResidence.shareCode
|
||||||
|
self.residenceName = sharedResidence.residenceName
|
||||||
|
self.sharedBy = sharedResidence.sharedBy
|
||||||
|
self.expiresAt = sharedResidence.expiresAt
|
||||||
|
self.exportedAt = sharedResidence.exportedAt
|
||||||
|
self.exportedBy = sharedResidence.exportedBy
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,20 @@ import WidgetKit
|
|||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@StateObject private var themeManager = ThemeManager.shared
|
@StateObject private var themeManager = ThemeManager.shared
|
||||||
@StateObject private var sharingManager = ContractorSharingManager.shared
|
@StateObject private var contractorSharingManager = ContractorSharingManager.shared
|
||||||
|
@StateObject private var residenceSharingManager = ResidenceSharingManager.shared
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var deepLinkResetToken: String?
|
@State private var deepLinkResetToken: String?
|
||||||
@State private var pendingImportURL: URL?
|
@State private var pendingImportURL: URL?
|
||||||
|
@State private var pendingImportType: CaseraPackageType = .contractor
|
||||||
@State private var showImportConfirmation: Bool = false
|
@State private var showImportConfirmation: Bool = false
|
||||||
|
|
||||||
|
/// Type of casera package being imported
|
||||||
|
enum CaseraPackageType {
|
||||||
|
case contractor
|
||||||
|
case residence
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize DataManager with platform-specific managers
|
// Initialize DataManager with platform-specific managers
|
||||||
// This must be done before any other operations that access DataManager
|
// This must be done before any other operations that access DataManager
|
||||||
@@ -37,7 +45,8 @@ struct iOSApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environmentObject(themeManager)
|
.environmentObject(themeManager)
|
||||||
.environmentObject(sharingManager)
|
.environmentObject(contractorSharingManager)
|
||||||
|
.environmentObject(residenceSharingManager)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleIncomingURL(url: url)
|
handleIncomingURL(url: url)
|
||||||
}
|
}
|
||||||
@@ -55,12 +64,19 @@ struct iOSApp: App {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Import confirmation dialog
|
// Import confirmation dialog - routes to appropriate handler
|
||||||
.alert("Import Contractor", isPresented: $showImportConfirmation) {
|
.alert(importConfirmationTitle, isPresented: $showImportConfirmation) {
|
||||||
Button("Import") {
|
Button("Import") {
|
||||||
if let url = pendingImportURL {
|
if let url = pendingImportURL {
|
||||||
sharingManager.importContractor(from: url) { _ in
|
switch pendingImportType {
|
||||||
pendingImportURL = nil
|
case .contractor:
|
||||||
|
contractorSharingManager.importContractor(from: url) { _ in
|
||||||
|
pendingImportURL = nil
|
||||||
|
}
|
||||||
|
case .residence:
|
||||||
|
residenceSharingManager.importResidence(from: url) { _ in
|
||||||
|
pendingImportURL = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,27 +84,66 @@ struct iOSApp: App {
|
|||||||
pendingImportURL = nil
|
pendingImportURL = nil
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text("Would you like to import this contractor to your contacts?")
|
Text(importConfirmationMessage)
|
||||||
}
|
}
|
||||||
// Import success dialog
|
// Contractor import success dialog
|
||||||
.alert("Contractor Imported", isPresented: $sharingManager.importSuccess) {
|
.alert("Contractor Imported", isPresented: $contractorSharingManager.importSuccess) {
|
||||||
Button("OK") {
|
Button("OK") {
|
||||||
sharingManager.resetImportState()
|
contractorSharingManager.resetImportState()
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text("\(sharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
|
Text("\(contractorSharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
|
||||||
}
|
}
|
||||||
// Import error dialog
|
// Contractor import error dialog
|
||||||
.alert("Import Failed", isPresented: .init(
|
.alert("Import Failed", isPresented: .init(
|
||||||
get: { sharingManager.importError != nil },
|
get: { contractorSharingManager.importError != nil },
|
||||||
set: { if !$0 { sharingManager.resetImportState() } }
|
set: { if !$0 { contractorSharingManager.resetImportState() } }
|
||||||
)) {
|
)) {
|
||||||
Button("OK") {
|
Button("OK") {
|
||||||
sharingManager.resetImportState()
|
contractorSharingManager.resetImportState()
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text(sharingManager.importError ?? "An error occurred while importing the contractor.")
|
Text(contractorSharingManager.importError ?? "An error occurred while importing the contractor.")
|
||||||
}
|
}
|
||||||
|
// Residence import success dialog
|
||||||
|
.alert("Joined Residence", isPresented: $residenceSharingManager.importSuccess) {
|
||||||
|
Button("OK") {
|
||||||
|
residenceSharingManager.resetState()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("You now have access to \(residenceSharingManager.importedResidenceName ?? "the residence").")
|
||||||
|
}
|
||||||
|
// Residence import error dialog
|
||||||
|
.alert("Join Failed", isPresented: .init(
|
||||||
|
get: { residenceSharingManager.errorMessage != nil && !residenceSharingManager.isImporting },
|
||||||
|
set: { if !$0 { residenceSharingManager.resetState() } }
|
||||||
|
)) {
|
||||||
|
Button("OK") {
|
||||||
|
residenceSharingManager.resetState()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(residenceSharingManager.errorMessage ?? "An error occurred while joining the residence.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Dialog Helpers
|
||||||
|
|
||||||
|
private var importConfirmationTitle: String {
|
||||||
|
switch pendingImportType {
|
||||||
|
case .contractor:
|
||||||
|
return "Import Contractor"
|
||||||
|
case .residence:
|
||||||
|
return "Join Residence"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var importConfirmationMessage: String {
|
||||||
|
switch pendingImportType {
|
||||||
|
case .contractor:
|
||||||
|
return "Would you like to import this contractor to your contacts?"
|
||||||
|
case .residence:
|
||||||
|
return "Would you like to join this shared residence?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +155,7 @@ struct iOSApp: App {
|
|||||||
|
|
||||||
// Handle .casera file imports
|
// Handle .casera file imports
|
||||||
if url.pathExtension.lowercased() == "casera" {
|
if url.pathExtension.lowercased() == "casera" {
|
||||||
handleContractorImport(url: url)
|
handleCaseraFileImport(url: url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,16 +168,43 @@ struct iOSApp: App {
|
|||||||
print("Unrecognized URL: \(url)")
|
print("Unrecognized URL: \(url)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles .casera file imports
|
/// Handles .casera file imports - detects type and routes accordingly
|
||||||
private func handleContractorImport(url: URL) {
|
private func handleCaseraFileImport(url: URL) {
|
||||||
print("Contractor file received: \(url)")
|
print("Casera file received: \(url)")
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
guard TokenStorage.shared.getToken() != nil else {
|
guard TokenStorage.shared.getToken() != nil else {
|
||||||
sharingManager.importError = "You must be logged in to import a contractor"
|
contractorSharingManager.importError = "You must be logged in to import"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read file and detect type
|
||||||
|
let accessing = url.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessing {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let typeString = json["type"] as? String {
|
||||||
|
// Route based on type
|
||||||
|
if typeString == "residence" {
|
||||||
|
pendingImportType = .residence
|
||||||
|
} else {
|
||||||
|
pendingImportType = .contractor
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default to contractor for backward compatibility (files without type field)
|
||||||
|
pendingImportType = .contractor
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to read casera file: \(error)")
|
||||||
|
pendingImportType = .contractor
|
||||||
|
}
|
||||||
|
|
||||||
// Store URL and show confirmation dialog
|
// Store URL and show confirmation dialog
|
||||||
pendingImportURL = url
|
pendingImportURL = url
|
||||||
showImportConfirmation = true
|
showImportConfirmation = true
|
||||||
|
|||||||
Reference in New Issue
Block a user