From 83e2cd14a62413201a6e4714cf49d0a8b7898d80 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 6 Dec 2025 18:54:46 -0600 Subject: [PATCH] Add residence sharing via .casera files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../kotlin/com/example/casera/MainActivity.kt | 38 ++- .../ResidenceImportHandler.android.kt | 22 ++ .../platform/ResidenceSharing.android.kt | 39 +++ .../casera/sharing/ResidenceSharingManager.kt | 122 +++++++++ .../ResidenceImportHandler.android.kt | 190 ++++++++++++++ .../kotlin/com/example/casera/App.kt | 11 +- .../example/casera/models/SharedContractor.kt | 66 +++++ .../com/example/casera/network/APILayer.kt | 5 + .../example/casera/network/ResidenceApi.kt | 17 ++ .../casera/platform/ResidenceImportHandler.kt | 20 ++ .../casera/platform/ResidenceSharing.kt | 24 ++ .../ui/components/ResidenceImportDialog.kt | 243 ++++++++++++++++++ .../ui/screens/ResidenceDetailScreen.kt | 43 ++++ .../platform/ResidenceImportHandler.ios.kt | 17 ++ .../casera/platform/ResidenceSharing.ios.kt | 15 ++ .../platform/ResidenceImportHandler.js.kt | 16 ++ .../casera/platform/ResidenceSharing.js.kt | 15 ++ .../platform/ResidenceImportHandler.jvm.kt | 16 ++ .../casera/platform/ResidenceSharing.jvm.kt | 15 ++ .../platform/ResidenceImportHandler.wasmJs.kt | 16 ++ .../platform/ResidenceSharing.wasmJs.kt | 15 ++ .../PreviewViewController.swift | 93 ++++++- .../CaseraQLThumbnail/ThumbnailProvider.swift | 35 ++- iosApp/iosApp/Localizable.xcstrings | 19 +- .../Residence/ResidenceDetailView.swift | 44 +++- .../Residence/ResidenceSharingManager.swift | 208 +++++++++++++++ iosApp/iosApp/iOSApp.swift | 124 +++++++-- 27 files changed, 1445 insertions(+), 43 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceImportHandler.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ResidenceImportHandler.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceImportHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceSharing.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt create mode 100644 composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceImportHandler.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt create mode 100644 composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.js.kt create mode 100644 composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceSharing.js.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceImportHandler.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceSharing.jvm.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.wasmJs.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceSharing.wasmJs.kt create mode 100644 iosApp/iosApp/Residence/ResidenceSharingManager.swift diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index 61528da..c5bb46a 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -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(null) private var navigateToTaskId by mutableStateOf(null) private var pendingContractorImportUri by mutableStateOf(null) + private var pendingResidenceImportUri by mutableStateOf(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 + } } } } diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceImportHandler.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceImportHandler.android.kt new file mode 100644 index 0000000..f54c704 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceImportHandler.android.kt @@ -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 + ) +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt new file mode 100644 index 0000000..d6f4ce3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt @@ -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 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) +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt new file mode 100644 index 0000000..667bd6c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt @@ -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 { + 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}") + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ResidenceImportHandler.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ResidenceImportHandler.android.kt new file mode 100644 index 0000000..8345339 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/ui/components/ResidenceImportHandler.android.kt @@ -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.Idle) } + var pendingUri by remember { mutableStateOf(null) } + var importedResponse by remember { mutableStateOf(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() + } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index 8d671bd..f11b519 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt index 102a823..6097fc9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/SharedContractor.kt @@ -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(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, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 3139f07..7a6059f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -494,6 +494,11 @@ object APILayer { return residenceApi.generateShareCode(token, residenceId) } + suspend fun generateSharePackage(residenceId: Int): ApiResult { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + return residenceApi.generateSharePackage(token, residenceId) + } + suspend fun removeUser(residenceId: Int, userId: Int): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.removeUser(token, residenceId, userId) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt index 5c77df0..06b9d02 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt @@ -126,6 +126,23 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { } // Share Code Management + suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult { + 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 { return try { val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceImportHandler.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceImportHandler.kt new file mode 100644 index 0000000..f2e7eaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceImportHandler.kt @@ -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 = {} +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceSharing.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceSharing.kt new file mode 100644 index 0000000..c60c978 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/ResidenceSharing.kt @@ -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 Unit> diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt new file mode 100644 index 0000000..c6b04b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt @@ -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") + } + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index e64a36a..b7ed08e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -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 = { diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceImportHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceImportHandler.ios.kt new file mode 100644 index 0000000..6b3b123 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceImportHandler.ios.kt @@ -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 +} diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt new file mode 100644 index 0000000..30e9239 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt @@ -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 Unit> { + val state = remember { ResidenceSharingState() } + val noOp: (Residence) -> Unit = { /* No-op on iOS - handled in Swift layer */ } + return Pair(state, noOp) +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.js.kt new file mode 100644 index 0000000..d47f7eb --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.js.kt @@ -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 +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceSharing.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceSharing.js.kt new file mode 100644 index 0000000..bccc31f --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/ResidenceSharing.js.kt @@ -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 Unit> { + val state = remember { ResidenceSharingState() } + val noOp: (Residence) -> Unit = { /* No-op on JS */ } + return Pair(state, noOp) +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceImportHandler.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceImportHandler.jvm.kt new file mode 100644 index 0000000..a19e38c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceImportHandler.jvm.kt @@ -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 +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceSharing.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceSharing.jvm.kt new file mode 100644 index 0000000..3929f03 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/ResidenceSharing.jvm.kt @@ -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 Unit> { + val state = remember { ResidenceSharingState() } + val noOp: (Residence) -> Unit = { /* No-op on JVM */ } + return Pair(state, noOp) +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.wasmJs.kt new file mode 100644 index 0000000..caa01f3 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceImportHandler.wasmJs.kt @@ -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 +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceSharing.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceSharing.wasmJs.kt new file mode 100644 index 0000000..5e63335 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/ResidenceSharing.wasmJs.kt @@ -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 Unit> { + val state = remember { ResidenceSharingState() } + val noOp: (Residence) -> Unit = { /* No-op on WasmJS */ } + return Pair(state, noOp) +} diff --git a/iosApp/CaseraQLPreview/PreviewViewController.swift b/iosApp/CaseraQLPreview/PreviewViewController.swift index 198e263..f10acce 100644 --- a/iosApp/CaseraQLPreview/PreviewViewController.swift +++ b/iosApp/CaseraQLPreview/PreviewViewController.swift @@ -3,6 +3,14 @@ import QuickLook class PreviewViewController: UIViewController, QLPreviewingController { + // MARK: - Types + + /// Represents the type of .casera package + private enum PackageType { + case contractor + case residence + } + // MARK: - UI Elements private let containerView: UIView = { @@ -89,6 +97,8 @@ class PreviewViewController: UIViewController, QLPreviewingController { // MARK: - Data private var contractorData: ContractorPreviewData? + private var residenceData: ResidencePreviewData? + private var currentPackageType: PackageType = .contractor // MARK: - Lifecycle @@ -187,20 +197,46 @@ class PreviewViewController: UIViewController, QLPreviewingController { func preparePreviewOfFile(at url: URL) async throws { print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)") + // Parse the .casera file 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 { - self.updateUI(with: contractor) + // Detect package type first + 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 + subtitleLabel.text = "Casera Contractor File" + instructionLabel.text = "Tap the share button below, then select \"Casera\" to import this contractor." // Clear existing details detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } @@ -227,6 +263,28 @@ class PreviewViewController: UIViewController, QLPreviewingController { 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 @@ -262,3 +320,24 @@ struct ContractorPreviewData: Codable { 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" + } +} diff --git a/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift b/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift index d89ed78..4db254a 100644 --- a/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift +++ b/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift @@ -10,19 +10,37 @@ import QuickLookThumbnailing 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) { let thumbnailSize = request.maximumSize + // Detect package type from file + let packageType = detectPackageType(at: request.fileURL) + handler(QLThumbnailReply(contextSize: thumbnailSize, currentContextDrawing: { () -> Bool in // Draw background let backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1) backgroundColor.setFill() 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 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 iconSize = tintedIcon.size let iconOrigin = CGPoint( @@ -35,4 +53,19 @@ class ThumbnailProvider: QLThumbnailProvider { return true }), 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 + } } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index a3cec2b..3cf76aa 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -17348,10 +17348,6 @@ "comment" : "The text on a button that triggers the import action.", "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" : { "comment" : "A dialog title when importing a contractor fails.", "isCommentAutoGenerated" : true @@ -17368,9 +17364,16 @@ "comment" : "A title for the registration screen.", "isCommentAutoGenerated" : true }, + "Join Failed" : { + "comment" : "An alert title displayed when joining a residence fails.", + "isCommentAutoGenerated" : true + }, "Join Residence" : { "comment" : "A button label that allows a user to join an existing residence.", "isCommentAutoGenerated" : true + }, + "Joined Residence" : { + }, "Joining 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.", "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 %@" : { "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 @@ -29683,8 +29690,8 @@ "comment" : "The title of the welcome screen in the preview.", "isCommentAutoGenerated" : true }, - "Would you like to import this contractor to your contacts?" : { - "comment" : "A message displayed in an alert when a user imports a contractor.", + "You now have access to %@." : { + "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 }, "You now have full access to all Pro features!" : { diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 4a84e2f..f4ad6d5 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -32,7 +32,10 @@ struct ResidenceDetailView: View { @State private var showDeleteConfirmation = false @State private var isDeleting = 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 sharingManager = ResidenceSharingManager.shared @Environment(\.dismiss) private var dismiss @@ -146,6 +149,22 @@ struct ResidenceDetailView: View { .sheet(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 .onChange(of: viewModel.reportMessage) { message in @@ -336,15 +355,21 @@ private extension ResidenceDetailView { } .disabled(viewModel.isGeneratingReport) } - + + // Share Residence button (owner only) if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) { Button { - showManageUsers = true + shareResidence(residence) } label: { - Image(systemName: "person.2") + if sharingManager.isGeneratingPackage { + ProgressView() + } else { + Image(systemName: "square.and.arrow.up") + } } + .disabled(sharingManager.isGeneratingPackage) } - + Button { // Check LIVE task count before adding let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0 @@ -357,7 +382,7 @@ private extension ResidenceDetailView { Image(systemName: "plus") } .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) - + if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) { Button { showDeleteConfirmation = true @@ -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 diff --git a/iosApp/iosApp/Residence/ResidenceSharingManager.swift b/iosApp/iosApp/Residence/ResidenceSharingManager.swift new file mode 100644 index 0000000..7ffd279 --- /dev/null +++ b/iosApp/iosApp/Residence/ResidenceSharingManager.swift @@ -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, + 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, + 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 + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 77abda0..6829f92 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -6,12 +6,20 @@ import WidgetKit struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @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 @State private var deepLinkResetToken: String? @State private var pendingImportURL: URL? + @State private var pendingImportType: CaseraPackageType = .contractor @State private var showImportConfirmation: Bool = false + /// Type of casera package being imported + enum CaseraPackageType { + case contractor + case residence + } + init() { // Initialize DataManager with platform-specific managers // This must be done before any other operations that access DataManager @@ -37,7 +45,8 @@ struct iOSApp: App { WindowGroup { RootView() .environmentObject(themeManager) - .environmentObject(sharingManager) + .environmentObject(contractorSharingManager) + .environmentObject(residenceSharingManager) .onOpenURL { url in handleIncomingURL(url: url) } @@ -55,12 +64,19 @@ struct iOSApp: App { WidgetCenter.shared.reloadAllTimelines() } } - // Import confirmation dialog - .alert("Import Contractor", isPresented: $showImportConfirmation) { + // Import confirmation dialog - routes to appropriate handler + .alert(importConfirmationTitle, isPresented: $showImportConfirmation) { Button("Import") { if let url = pendingImportURL { - sharingManager.importContractor(from: url) { _ in - pendingImportURL = nil + switch pendingImportType { + 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 } } message: { - Text("Would you like to import this contractor to your contacts?") + Text(importConfirmationMessage) } - // Import success dialog - .alert("Contractor Imported", isPresented: $sharingManager.importSuccess) { + // Contractor import success dialog + .alert("Contractor Imported", isPresented: $contractorSharingManager.importSuccess) { Button("OK") { - sharingManager.resetImportState() + contractorSharingManager.resetImportState() } } 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( - get: { sharingManager.importError != nil }, - set: { if !$0 { sharingManager.resetImportState() } } + get: { contractorSharingManager.importError != nil }, + set: { if !$0 { contractorSharingManager.resetImportState() } } )) { Button("OK") { - sharingManager.resetImportState() + contractorSharingManager.resetImportState() } } 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 if url.pathExtension.lowercased() == "casera" { - handleContractorImport(url: url) + handleCaseraFileImport(url: url) return } @@ -113,16 +168,43 @@ struct iOSApp: App { print("Unrecognized URL: \(url)") } - /// Handles .casera file imports - private func handleContractorImport(url: URL) { - print("Contractor file received: \(url)") + /// Handles .casera file imports - detects type and routes accordingly + private func handleCaseraFileImport(url: URL) { + print("Casera file received: \(url)") // Check if user is authenticated 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 } + // 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 pendingImportURL = url showImportConfirmation = true