Add contractor sharing feature and move settings to navigation bar

Contractor Sharing:
- Add .casera file format for sharing contractors between users
- Create SharedContractor model with JSON serialization
- Implement ContractorSharingManager for iOS (Swift) and Android (Kotlin)
- Register .casera file type in iOS Info.plist and Android manifest
- Add share button to ContractorDetailView (iOS) and ContractorDetailScreen (Android)
- Add import confirmation, success, and error dialogs
- Create expect/actual platform implementations for sharing and import handling

Navigation Changes:
- Remove Profile tab from bottom tab bar (iOS and Android)
- Add settings gear icon to left side of "My Properties" title
- Settings gear opens Profile/Settings screen as sheet (iOS) or navigates (Android)
- Add property button to top right action bar

Bug Fixes:
- Fix ResidenceUsersResponse to match API's flat array response format
- Fix GenerateShareCodeResponse handling to access nested shareCode property
- Update ManageUsersDialog to accept residenceOwnerId parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 22:30:19 -06:00
parent 2965ec4031
commit 859a6679ed
43 changed files with 1848 additions and 148 deletions

View File

@@ -39,6 +39,28 @@
android:scheme="casera"
android:host="reset-password" />
</intent-filter>
<!-- .casera file import -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.casera" />
</intent-filter>
<!-- .casera file import via content:// -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
</activity>
<!-- FileProvider for camera photos -->

View File

@@ -31,11 +31,14 @@ import com.example.casera.storage.ThemeStorageManager
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.fcm.FCMManager
import com.example.casera.platform.BillingManager
import com.example.casera.network.APILayer
import com.example.casera.sharing.ContractorSharingManager
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
private var deepLinkResetToken by mutableStateOf<String?>(null)
private var navigateToTaskId by mutableStateOf<Int?>(null)
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
private lateinit var billingManager: BillingManager
override fun onCreate(savedInstanceState: Bundle?) {
@@ -55,9 +58,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
// Initialize BillingManager for subscription management
billingManager = BillingManager.getInstance(applicationContext)
// Handle deep link and notification navigation from intent
// Handle deep link, notification navigation, and file import from intent
handleDeepLink(intent)
handleNotificationNavigation(intent)
handleFileImport(intent)
// Request notification permission and setup FCM
setupFCM()
@@ -74,6 +78,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = {
navigateToTaskId = null
},
pendingContractorImportUri = pendingContractorImportUri,
onClearContractorImport = {
pendingContractorImportUri = null
}
)
}
@@ -183,10 +191,21 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
}
}
override fun onResume() {
super.onResume()
// Check if lookups have changed on server (efficient ETag-based check)
// This ensures app has fresh data when coming back from background
lifecycleScope.launch {
Log.d("MainActivity", "🔄 App resumed, checking for lookup updates...")
APILayer.refreshLookupsIfChanged()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
handleNotificationNavigation(intent)
handleFileImport(intent)
}
private fun handleNotificationNavigation(intent: Intent?) {
@@ -209,6 +228,16 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
}
}
private fun handleFileImport(intent: Intent?) {
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
}
}
}
override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(context)
.components {

View File

@@ -0,0 +1,22 @@
package com.example.casera.platform
import android.net.Uri
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
import com.example.casera.ui.components.ContractorImportHandler as ContractorImportHandlerImpl
@Composable
actual fun ContractorImportHandler(
pendingContractorImportUri: Any?,
onClearContractorImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit
) {
// Cast to Android Uri
val uri = pendingContractorImportUri as? Uri
ContractorImportHandlerImpl(
pendingImportUri = uri,
onClearImport = onClearContractorImport,
onImportSuccess = onImportSuccess
)
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.platform
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.example.casera.models.Contractor
import com.example.casera.sharing.ContractorSharingManager
@Composable
actual fun rememberShareContractor(): (Contractor) -> Unit {
val context = LocalContext.current
return { contractor: Contractor ->
val intent = ContractorSharingManager.createShareIntent(context, contractor)
if (intent != null) {
context.startActivity(Intent.createChooser(intent, "Share Contractor"))
}
}
}

View File

@@ -0,0 +1,148 @@
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.Contractor
import com.example.casera.models.SharedContractor
import com.example.casera.models.resolveSpecialtyIds
import com.example.casera.models.toCreateRequest
import com.example.casera.models.toSharedContractor
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 contractor export and import via .casera files on Android.
*/
object ContractorSharingManager {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
/**
* Creates a share Intent for a contractor.
* The contractor data is written to a temporary .casera file and shared via FileProvider.
*
* @param context Android context
* @param contractor The contractor to share
* @return Share Intent or null if creation failed
*/
fun createShareIntent(context: Context, contractor: Contractor): Intent? {
return try {
val currentUsername = DataManager.currentUser.value?.username ?: "Unknown"
val sharedContractor = contractor.toSharedContractor(currentUsername)
val jsonString = json.encodeToString(SharedContractor.serializer(), sharedContractor)
// Create safe filename
val safeName = contractor.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, "Contractor: ${contractor.name}")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* Imports a contractor from a content URI.
*
* @param context Android context
* @param uri The content URI of the .casera file
* @return ApiResult with the created Contractor on success, or error on failure
*/
suspend fun importContractor(context: Context, uri: Uri): ApiResult<Contractor> {
return withContext(Dispatchers.IO) {
try {
// Check authentication
if (DataManager.authToken.value == null) {
return@withContext ApiResult.Error("You must be logged in to import a contractor", 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 sharedContractor = json.decodeFromString(SharedContractor.serializer(), jsonString)
// Resolve specialty names to IDs
val specialties = DataManager.contractorSpecialties.value
val specialtyIds = sharedContractor.resolveSpecialtyIds(specialties)
// Create the request
val createRequest = sharedContractor.toCreateRequest(specialtyIds)
// Call API
APILayer.createContractor(createRequest)
} catch (e: Exception) {
e.printStackTrace()
ApiResult.Error("Failed to import contractor: ${e.message}")
}
}
}
/**
* Checks if the given URI appears to be a .casera file.
*/
fun isCaseraFile(context: Context, uri: Uri): Boolean {
// Check file extension from URI path
val path = uri.path ?: uri.toString()
if (path.endsWith(".casera", ignoreCase = true)) {
return true
}
// Try to get display name from content resolver
try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) {
val name = cursor.getString(nameIndex)
if (name?.endsWith(".casera", ignoreCase = true) == true) {
return true
}
}
}
}
} catch (e: Exception) {
// Ignore errors, fall through to false
}
return false
}
}

View File

@@ -0,0 +1,190 @@
package com.example.casera.ui.components
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.example.casera.models.Contractor
import com.example.casera.models.SharedContractor
import com.example.casera.network.ApiResult
import com.example.casera.sharing.ContractorSharingManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Represents the current state of the contractor import flow.
*/
sealed class ImportState {
data object Idle : ImportState()
data class Confirmation(val sharedContractor: SharedContractor) : ImportState()
data class Importing(val sharedContractor: SharedContractor) : ImportState()
data class Success(val contractorName: String) : ImportState()
data class Error(val message: String) : ImportState()
}
/**
* Android-specific composable that handles the contractor 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 imported contractor
*/
@Composable
fun ContractorImportHandler(
pendingImportUri: Uri?,
onClearImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var importState by remember { mutableStateOf<ImportState>(ImportState.Idle) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var importedContractor by remember { mutableStateOf<Contractor?>(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 ImportState.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 sharedContractor = json.decodeFromString(
SharedContractor.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ImportState.Confirmation(sharedContractor)
}
} else {
withContext(Dispatchers.Main) {
importState = ImportState.Error("Could not open file")
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
importState = ImportState.Error("Invalid contractor file: ${e.message}")
}
}
}
}
}
// Show appropriate dialog based on state
when (val state = importState) {
is ImportState.Idle -> {
// No dialog
}
is ImportState.Confirmation -> {
ContractorImportConfirmDialog(
sharedContractor = state.sharedContractor,
isImporting = false,
onConfirm = {
importState = ImportState.Importing(state.sharedContractor)
scope.launch {
pendingUri?.let { uri ->
when (val result = ContractorSharingManager.importContractor(context, uri)) {
is ApiResult.Success -> {
importedContractor = result.data
importState = ImportState.Success(result.data.name)
}
is ApiResult.Error -> {
importState = ImportState.Error(result.message)
}
else -> {
importState = ImportState.Error("Import failed unexpectedly")
}
}
}
}
},
onDismiss = {
importState = ImportState.Idle
pendingUri = null
onClearImport()
}
)
}
is ImportState.Importing -> {
// Show the confirmation dialog with loading state
ContractorImportConfirmDialog(
sharedContractor = state.sharedContractor,
isImporting = true,
onConfirm = {},
onDismiss = {}
)
}
is ImportState.Success -> {
ContractorImportSuccessDialog(
contractorName = state.contractorName,
onDismiss = {
importedContractor?.let { onImportSuccess(it) }
importState = ImportState.Idle
pendingUri = null
importedContractor = null
onClearImport()
}
)
}
is ImportState.Error -> {
ContractorImportErrorDialog(
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 sharedContractor = json.decodeFromString(
SharedContractor.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ImportState.Confirmation(sharedContractor)
}
}
} catch (e: Exception) {
// Keep showing error
}
}
}
}
},
onDismiss = {
importState = ImportState.Idle
pendingUri = null
onClearImport()
}
)
}
}
}

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="camera_images" path="/" />
<cache-path name="shared_contractors" path="shared/" />
</paths>

View File

@@ -246,6 +246,13 @@
<string name="contractors_delete">Delete Contractor</string>
<string name="contractors_delete_warning">Are you sure you want to delete this contractor? This action cannot be undone.</string>
<string name="contractors_completed_tasks">%1$d completed tasks</string>
<string name="contractors_share">Share Contractor</string>
<string name="contractors_import_title">Import Contractor</string>
<string name="contractors_import_message">Would you like to import this contractor?</string>
<string name="contractors_import_success">Contractor Imported</string>
<string name="contractors_import_success_message">%1$s has been added to your contacts.</string>
<string name="contractors_import_failed">Import Failed</string>
<string name="contractors_shared_by">Shared by: %1$s</string>
<!-- Documents -->
<string name="documents_title">Documents</string>
@@ -423,6 +430,9 @@
<string name="common_yes">Yes</string>
<string name="common_no">No</string>
<string name="common_ok">OK</string>
<string name="common_share">Share</string>
<string name="common_import">Import</string>
<string name="common_importing">Importing...</string>
<!-- Errors -->
<string name="error_generic">Something went wrong. Please try again.</string>

View File

@@ -57,6 +57,7 @@ import com.example.casera.network.ApiResult
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 casera.composeapp.generated.resources.Res
import casera.composeapp.generated.resources.compose_multiplatform
@@ -67,7 +68,9 @@ fun App(
deepLinkResetToken: String? = null,
onClearDeepLinkToken: () -> Unit = {},
navigateToTaskId: Int? = null,
onClearNavigateToTask: () -> Unit = {}
onClearNavigateToTask: () -> Unit = {},
pendingContractorImportUri: Any? = null,
onClearContractorImport: () -> Unit = {}
) {
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
var isVerified by remember { mutableStateOf(false) }
@@ -110,6 +113,12 @@ fun App(
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
MyCribTheme(themeColors = currentTheme) {
// Handle contractor file imports (Android-specific, no-op on other platforms)
ContractorImportHandler(
pendingContractorImportUri = pendingContractorImportUri,
onClearContractorImport = onClearContractorImport
)
if (isCheckingAuth) {
// Show loading screen while checking auth
Surface(

View File

@@ -205,6 +205,11 @@ object DataManager {
private val _lastSyncTime = MutableStateFlow(0L)
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
// ==================== SEEDED DATA ETAG ====================
private val _seededDataETag = MutableStateFlow<String?>(null)
val seededDataETag: StateFlow<String?> = _seededDataETag.asStateFlow()
// ==================== INITIALIZATION ====================
/**
@@ -584,6 +589,34 @@ object DataManager {
_lookupsInitialized.value = true
}
/**
* Set all lookups from unified seeded data response.
* Also stores the ETag for future conditional requests.
*/
fun setAllLookupsFromSeededData(seededData: SeededDataResponse, etag: String?) {
setResidenceTypes(seededData.residenceTypes)
setTaskFrequencies(seededData.taskFrequencies)
setTaskPriorities(seededData.taskPriorities)
setTaskStatuses(seededData.taskStatuses)
setTaskCategories(seededData.taskCategories)
setContractorSpecialties(seededData.contractorSpecialties)
setTaskTemplatesGrouped(seededData.taskTemplates)
setSeededDataETag(etag)
_lookupsInitialized.value = true
}
/**
* Set the ETag for seeded data. Used for conditional requests.
*/
fun setSeededDataETag(etag: String?) {
_seededDataETag.value = etag
if (etag != null) {
persistenceManager?.save(KEY_SEEDED_DATA_ETAG, etag)
} else {
persistenceManager?.remove(KEY_SEEDED_DATA_ETAG)
}
}
fun markLookupsInitialized() {
_lookupsInitialized.value = true
}
@@ -632,6 +665,7 @@ object DataManager {
_taskTemplates.value = emptyList()
_taskTemplatesGrouped.value = null
_lookupsInitialized.value = false
_seededDataETag.value = null
// Clear cache timestamps
residencesCacheTime = 0L
@@ -723,6 +757,11 @@ object DataManager {
manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data ->
_hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false
}
// Load seeded data ETag for conditional requests
manager.load(KEY_SEEDED_DATA_ETAG)?.let { data ->
_seededDataETag.value = data
}
} catch (e: Exception) {
println("DataManager: Error loading from disk: ${e.message}")
}
@@ -733,4 +772,5 @@ object DataManager {
private const val KEY_CURRENT_USER = "dm_current_user"
private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding"
private const val KEY_SEEDED_DATA_ETAG = "dm_seeded_data_etag"
}

View File

@@ -109,6 +109,21 @@ data class StaticDataResponse(
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>
)
/**
* Unified seeded data response - all lookups + task templates in one call
* Supports ETag-based conditional fetching for efficient caching
*/
@Serializable
data class SeededDataResponse(
@SerialName("residence_types") val residenceTypes: List<ResidenceType>,
@SerialName("task_categories") val taskCategories: List<TaskCategory>,
@SerialName("task_priorities") val taskPriorities: List<TaskPriority>,
@SerialName("task_frequencies") val taskFrequencies: List<TaskFrequency>,
@SerialName("task_statuses") val taskStatuses: List<TaskStatus>,
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>,
@SerialName("task_templates") val taskTemplates: TaskTemplatesGroupedResponse
)
// Legacy wrapper responses for backward compatibility
// These can be removed once all code is migrated to use arrays directly

View File

@@ -232,13 +232,9 @@ data class ResidenceTaskSummary(
)
/**
* Residence users response
* Residence users response - API returns a flat list of all users with access
*/
@Serializable
data class ResidenceUsersResponse(
val owner: ResidenceUserResponse,
val users: List<ResidenceUserResponse>
)
typealias ResidenceUsersResponse = List<ResidenceUserResponse>
/**
* Remove user response

View File

@@ -0,0 +1,107 @@
package com.example.casera.models
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Data model for .casera file format used to share contractors between users.
* Contains only the data needed to recreate a contractor, without server-specific IDs.
*/
@Serializable
data class SharedContractor(
/** File format version for future compatibility */
val version: Int = 1,
val name: String,
val company: String? = null,
val phone: String? = null,
val email: String? = null,
val website: String? = null,
val notes: String? = null,
@SerialName("street_address")
val streetAddress: String? = null,
val city: String? = null,
@SerialName("state_province")
val stateProvince: String? = null,
@SerialName("postal_code")
val postalCode: String? = null,
/** Specialty names (not IDs) for cross-account compatibility */
@SerialName("specialty_names")
val specialtyNames: List<String> = emptyList(),
val rating: Double? = null,
@SerialName("is_favorite")
val isFavorite: Boolean = false,
/** ISO8601 timestamp when the contractor was exported */
@SerialName("exported_at")
val exportedAt: String? = null,
/** Username of the person who exported the contractor */
@SerialName("exported_by")
val exportedBy: String? = null
)
/**
* Convert a full Contractor to SharedContractor for export.
*/
@OptIn(ExperimentalTime::class)
fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor {
return SharedContractor(
version = 1,
name = name,
company = company,
phone = phone,
email = email,
website = website,
notes = notes,
streetAddress = streetAddress,
city = city,
stateProvince = stateProvince,
postalCode = postalCode,
specialtyNames = specialties.map { it.name },
rating = rating,
isFavorite = isFavorite,
exportedAt = Clock.System.now().toString(),
exportedBy = exportedBy
)
}
/**
* Convert SharedContractor to ContractorCreateRequest for import.
* @param specialtyIds The resolved specialty IDs from the importing account's lookup data
*/
fun SharedContractor.toCreateRequest(specialtyIds: List<Int>): ContractorCreateRequest {
return ContractorCreateRequest(
name = name,
residenceId = null, // Imported contractors have no residence association
company = company,
phone = phone,
email = email,
website = website,
streetAddress = streetAddress,
city = city,
stateProvince = stateProvince,
postalCode = postalCode,
rating = rating,
isFavorite = isFavorite,
notes = notes,
specialtyIds = specialtyIds.ifEmpty { null }
)
}
/**
* Resolve specialty names to IDs using the available specialties in the importing account.
* Case-insensitive matching.
*/
fun SharedContractor.resolveSpecialtyIds(availableSpecialties: List<ContractorSpecialty>): List<Int> {
return specialtyNames.mapNotNull { name ->
availableSpecialties.find { specialty ->
specialty.name.equals(name, ignoreCase = true)
}?.id
}
}

View File

@@ -62,39 +62,47 @@ object APILayer {
* Initialize all lookup data. Can be called at app start even without authentication.
* Loads all reference data (residence types, task categories, priorities, etc.) into DataManager.
*
* Uses ETag-based conditional fetching - if data hasn't changed on server, returns 304 Not Modified
* and uses existing cached data. This is efficient for app foreground/resume scenarios.
*
* - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required)
* - /subscription/status/ requires auth and is only called if user is authenticated
*/
suspend fun initializeLookups(): ApiResult<Unit> {
val token = getToken()
val currentETag = DataManager.seededDataETag.value
if (DataManager.lookupsInitialized.value) {
// Lookups already initialized, but refresh subscription status if authenticated
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
if (token != null) {
refreshSubscriptionStatus()
}
return ApiResult.Success(Unit)
// If lookups are already initialized and we have an ETag, do conditional fetch
if (DataManager.lookupsInitialized.value && currentETag != null) {
println("📋 [APILayer] Lookups initialized, checking for updates with ETag...")
return refreshLookupsIfChanged()
}
try {
// Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required)
println("🔄 Fetching static data (all lookups)...")
val staticDataResult = lookupsApi.getStaticData(token) // token is optional
println("📦 Static data result: $staticDataResult")
// Use seeded data endpoint with ETag support (PUBLIC - no auth required)
println("🔄 Fetching seeded data (all lookups + templates)...")
val seededDataResult = lookupsApi.getSeededData(currentETag, token)
println("📦 Seeded data result: $seededDataResult")
// Update DataManager with all lookups at once
if (staticDataResult is ApiResult.Success) {
DataManager.setAllLookups(staticDataResult.data)
println("All lookups loaded successfully")
} else if (staticDataResult is ApiResult.Error) {
println("❌ Failed to fetch static data: ${staticDataResult.message}")
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
when (seededDataResult) {
is ConditionalResult.Success -> {
println("✅ Seeded data loaded successfully")
DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag)
}
is ConditionalResult.NotModified -> {
println("✅ Seeded data not modified, using cached data")
DataManager.markLookupsInitialized()
}
is ConditionalResult.Error -> {
println("❌ Failed to fetch seeded data: ${seededDataResult.message}")
// Fallback to old static_data endpoint without task templates
return fallbackToLegacyStaticData(token)
}
}
// Load upgrade triggers (PUBLIC - no auth required)
println("🔄 Fetching upgrade triggers...")
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
println("📦 Upgrade triggers result: $upgradeTriggersResult")
if (upgradeTriggersResult is ApiResult.Success) {
@@ -122,20 +130,6 @@ object APILayer {
println("⏭️ Skipping subscription status (not authenticated)")
}
// Load task templates (PUBLIC - no auth required)
println("🔄 Fetching task templates...")
val templatesResult = taskTemplateApi.getTemplatesGrouped()
println("📦 Task templates result: $templatesResult")
if (templatesResult is ApiResult.Success) {
println("✅ Updating task templates with ${templatesResult.data.totalCount} templates")
DataManager.setTaskTemplatesGrouped(templatesResult.data)
println("✅ Task templates updated successfully")
} else if (templatesResult is ApiResult.Error) {
println("❌ Failed to fetch task templates: ${templatesResult.message}")
// Non-fatal error - templates are optional for app functionality
}
DataManager.markLookupsInitialized()
return ApiResult.Success(Unit)
} catch (e: Exception) {
@@ -143,6 +137,68 @@ object APILayer {
}
}
/**
* Refresh lookups only if data has changed on server (using ETag).
* Called when app comes to foreground or resumes.
* Returns quickly with 304 Not Modified if data hasn't changed.
*/
suspend fun refreshLookupsIfChanged(): ApiResult<Unit> {
val token = getToken()
val currentETag = DataManager.seededDataETag.value
println("🔄 [APILayer] Checking if lookups have changed (ETag: $currentETag)...")
val seededDataResult = lookupsApi.getSeededData(currentETag, token)
when (seededDataResult) {
is ConditionalResult.Success -> {
println("✅ Lookups have changed, updating DataManager")
DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag)
}
is ConditionalResult.NotModified -> {
println("✅ Lookups unchanged (304 Not Modified)")
}
is ConditionalResult.Error -> {
println("❌ Failed to check lookup updates: ${seededDataResult.message}")
// Non-fatal - continue using cached data
}
}
// Refresh subscription status if authenticated
if (token != null) {
refreshSubscriptionStatus()
}
return ApiResult.Success(Unit)
}
/**
* Fallback to legacy static_data endpoint if seeded_data fails.
* Does not include task templates.
*/
private suspend fun fallbackToLegacyStaticData(token: String?): ApiResult<Unit> {
println("🔄 Falling back to legacy static data endpoint...")
val staticDataResult = lookupsApi.getStaticData(token)
if (staticDataResult is ApiResult.Success) {
DataManager.setAllLookups(staticDataResult.data)
println("✅ Legacy static data loaded successfully")
// Try to load task templates separately
val templatesResult = taskTemplateApi.getTemplatesGrouped()
if (templatesResult is ApiResult.Success) {
DataManager.setTaskTemplatesGrouped(templatesResult.data)
}
DataManager.markLookupsInitialized()
return ApiResult.Success(Unit)
} else if (staticDataResult is ApiResult.Error) {
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
}
return ApiResult.Error("Unknown error loading lookups")
}
/**
* Get residence types from DataManager. If cache is empty, fetch from API.
*/
@@ -893,95 +949,98 @@ object APILayer {
}
// ==================== Task Template Operations ====================
// Task templates are now included in seeded data, so these methods primarily use cache.
// If forceRefresh is needed, use refreshLookupsIfChanged() to get fresh data from server.
/**
* Get all task templates from DataManager. If cache is empty, fetch from API.
* Task templates are PUBLIC (no auth required).
* Get all task templates from DataManager.
* Templates are loaded with seeded data, so this uses cache.
* Use forceRefresh to trigger a full seeded data refresh.
*/
suspend fun getTaskTemplates(forceRefresh: Boolean = false): ApiResult<List<TaskTemplate>> {
if (!forceRefresh) {
val cached = DataManager.taskTemplates.value
if (cached.isNotEmpty()) {
return ApiResult.Success(cached)
}
if (forceRefresh) {
// Force refresh via seeded data endpoint (includes templates)
refreshLookupsIfChanged()
}
val result = taskTemplateApi.getTemplates()
if (result is ApiResult.Success) {
DataManager.setTaskTemplates(result.data)
val cached = DataManager.taskTemplates.value
if (cached.isNotEmpty()) {
return ApiResult.Success(cached)
}
return result
// If still empty, initialize lookups (which includes templates via seeded data)
initializeLookups()
return ApiResult.Success(DataManager.taskTemplates.value)
}
/**
* Get task templates grouped by category.
* Task templates are PUBLIC (no auth required).
* Templates are loaded with seeded data, so this uses cache.
*/
suspend fun getTaskTemplatesGrouped(forceRefresh: Boolean = false): ApiResult<TaskTemplatesGroupedResponse> {
if (!forceRefresh) {
val cached = DataManager.taskTemplatesGrouped.value
if (cached != null) {
return ApiResult.Success(cached)
}
if (forceRefresh) {
// Force refresh via seeded data endpoint (includes templates)
refreshLookupsIfChanged()
}
val result = taskTemplateApi.getTemplatesGrouped()
if (result is ApiResult.Success) {
DataManager.setTaskTemplatesGrouped(result.data)
}
return result
}
/**
* Search task templates by query string.
* First searches local cache, falls back to API if needed.
*/
suspend fun searchTaskTemplates(query: String): ApiResult<List<TaskTemplate>> {
// Try local search first if we have templates cached
val cached = DataManager.taskTemplates.value
if (cached.isNotEmpty()) {
val results = DataManager.searchTaskTemplates(query)
return ApiResult.Success(results)
}
// Fall back to API search
return taskTemplateApi.searchTemplates(query)
}
/**
* Get templates by category ID.
*/
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
// Try to get from grouped cache first
val grouped = DataManager.taskTemplatesGrouped.value
if (grouped != null) {
val categoryTemplates = grouped.categories
.find { it.categoryId == categoryId }?.templates
if (categoryTemplates != null) {
return ApiResult.Success(categoryTemplates)
}
}
// Fall back to API
return taskTemplateApi.getTemplatesByCategory(categoryId)
}
/**
* Get a single task template by ID.
*/
suspend fun getTaskTemplate(id: Int): ApiResult<TaskTemplate> {
// Try to find in cache first
val cached = DataManager.taskTemplates.value.find { it.id == id }
val cached = DataManager.taskTemplatesGrouped.value
if (cached != null) {
return ApiResult.Success(cached)
}
// Fall back to API
return taskTemplateApi.getTemplate(id)
// If still empty, initialize lookups (which includes templates via seeded data)
initializeLookups()
return DataManager.taskTemplatesGrouped.value?.let {
ApiResult.Success(it)
} ?: ApiResult.Error("Failed to load task templates")
}
/**
* Search task templates by query string.
* Uses local cache only - templates are loaded with seeded data.
*/
suspend fun searchTaskTemplates(query: String): ApiResult<List<TaskTemplate>> {
// Ensure templates are loaded
if (DataManager.taskTemplates.value.isEmpty()) {
initializeLookups()
}
val results = DataManager.searchTaskTemplates(query)
return ApiResult.Success(results)
}
/**
* Get templates by category ID.
* Uses local cache only - templates are loaded with seeded data.
*/
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
// Ensure templates are loaded
if (DataManager.taskTemplatesGrouped.value == null) {
initializeLookups()
}
val grouped = DataManager.taskTemplatesGrouped.value
val categoryTemplates = grouped?.categories
?.find { it.categoryId == categoryId }?.templates
?: emptyList()
return ApiResult.Success(categoryTemplates)
}
/**
* Get a single task template by ID.
* Uses local cache only - templates are loaded with seeded data.
*/
suspend fun getTaskTemplate(id: Int): ApiResult<TaskTemplate> {
// Ensure templates are loaded
if (DataManager.taskTemplates.value.isEmpty()) {
initializeLookups()
}
val cached = DataManager.taskTemplates.value.find { it.id == id }
return cached?.let {
ApiResult.Success(it)
} ?: ApiResult.Error("Task template not found")
}
// ==================== Auth Operations ====================

View File

@@ -4,8 +4,32 @@ import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
/**
* Result type for conditional HTTP requests with ETag support.
* Used to efficiently check if data has changed on the server.
*/
sealed class ConditionalResult<T> {
/**
* Server returned new data (HTTP 200).
* Includes the new ETag for future conditional requests.
*/
data class Success<T>(val data: T, val etag: String?) : ConditionalResult<T>()
/**
* Data has not changed since the provided ETag (HTTP 304).
* Client should continue using cached data.
*/
class NotModified<T> : ConditionalResult<T>()
/**
* Request failed with an error.
*/
data class Error<T>(val message: String, val statusCode: Int? = null) : ConditionalResult<T>()
}
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
@@ -137,4 +161,47 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Fetches unified seeded data (all lookups + task templates) with ETag support.
*
* @param currentETag The ETag from a previous response. If provided and data hasn't changed,
* server returns 304 Not Modified.
* @param token Optional auth token (endpoint is public).
* @return ConditionalResult with data and new ETag, NotModified if unchanged, or Error.
*/
suspend fun getSeededData(
currentETag: String? = null,
token: String? = null
): ConditionalResult<SeededDataResponse> {
return try {
val response: HttpResponse = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") }
// Send If-None-Match header for conditional request
currentETag?.let { header("If-None-Match", it) }
}
when {
response.status == HttpStatusCode.NotModified -> {
// Data hasn't changed since provided ETag
ConditionalResult.NotModified()
}
response.status.isSuccess() -> {
// Data has changed or first request - get new data and ETag
val data: SeededDataResponse = response.body()
val newETag = response.headers["ETag"]
ConditionalResult.Success(data, newETag)
}
else -> {
ConditionalResult.Error(
"Failed to fetch seeded data",
response.status.value
)
}
}
} catch (e: Exception) {
ConditionalResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,20 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
/**
* Platform-specific composable that handles contractor import flow.
* On Android, shows dialogs to confirm and execute import.
* On other platforms, this is a no-op.
*
* @param pendingContractorImportUri Platform-specific URI object (e.g., android.net.Uri)
* @param onClearContractorImport Called when import flow is complete
* @param onImportSuccess Called when a contractor is successfully imported
*/
@Composable
expect fun ContractorImportHandler(
pendingContractorImportUri: Any?,
onClearContractorImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit = {}
)

View File

@@ -0,0 +1,11 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
/**
* Returns a function that can be called to share a contractor.
* The returned function will open the native share sheet with a .casera file.
*/
@Composable
expect fun rememberShareContractor(): (Contractor) -> Unit

View File

@@ -0,0 +1,254 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.PersonAdd
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.SharedContractor
/**
* Dialog shown when a user attempts to import a contractor from a .casera file.
* Shows contractor details and asks for confirmation.
*/
@Composable
fun ContractorImportConfirmDialog(
sharedContractor: SharedContractor,
isImporting: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = { if (!isImporting) onDismiss() },
icon = {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
},
title = {
Text(
text = "Import Contractor",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Would you like to import this contractor?",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// Contractor details
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
text = sharedContractor.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
sharedContractor.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (sharedContractor.specialtyNames.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = sharedContractor.specialtyNames.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
sharedContractor.exportedBy?.let { exportedBy ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Shared by: $exportedBy",
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("Importing...")
} else {
Text("Import")
}
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isImporting
) {
Text("Cancel")
}
}
)
}
/**
* Dialog shown after a contractor import attempt succeeds.
*/
@Composable
fun ContractorImportSuccessDialog(
contractorName: 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 = "Contractor Imported",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
},
text = {
Text(
text = "$contractorName has been added to your contacts.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
},
confirmButton = {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("OK")
}
}
)
}
/**
* Dialog shown after a contractor import attempt fails.
*/
@Composable
fun ContractorImportErrorDialog(
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 = "Import 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")
}
}
}
)
}

View File

@@ -24,11 +24,12 @@ fun ManageUsersDialog(
residenceId: Int,
residenceName: String,
isPrimaryOwner: Boolean,
residenceOwnerId: Int,
onDismiss: () -> Unit,
onUserRemoved: () -> Unit = {}
) {
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
var ownerId by remember { mutableStateOf<Int?>(null) }
val ownerId = residenceOwnerId
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
@@ -46,8 +47,7 @@ fun ManageUsersDialog(
if (token != null) {
when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
is ApiResult.Success -> {
users = result.data.users
ownerId = result.data.owner.id
users = result.data
isLoading = false
}
is ApiResult.Error -> {

View File

@@ -28,6 +28,7 @@ import com.example.casera.ui.components.HandleErrors
import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.network.ApiResult
import com.example.casera.platform.rememberShareContractor
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -45,6 +46,8 @@ fun ContractorDetailScreen(
var showEditDialog by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
val shareContractor = rememberShareContractor()
LaunchedEffect(contractorId) {
viewModel.loadContractorDetail(contractorId)
}
@@ -87,6 +90,9 @@ fun ContractorDetailScreen(
actions = {
when (val state = contractorState) {
is ApiResult.Success -> {
IconButton(onClick = { shareContractor(state.data) }) {
Icon(Icons.Default.Share, stringResource(Res.string.common_share))
}
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
Icon(
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,

View File

@@ -232,6 +232,7 @@ fun ResidenceDetailScreen(
residenceId = residence.id,
residenceName = residence.name,
isPrimaryOwner = residence.ownerId == currentUser?.id,
residenceOwnerId = residence.ownerId,
onDismiss = {
showManageUsersDialog = false
},

View File

@@ -113,6 +113,15 @@ fun ResidencesScreen(
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onNavigateToProfile) {
Icon(
Icons.Default.Settings,
contentDescription = stringResource(Res.string.profile_title),
tint = MaterialTheme.colorScheme.primary
)
}
},
actions = {
// Only show Join button if not blocked (limit>0)
if (!isBlocked.allowed) {
@@ -128,11 +137,23 @@ fun ResidencesScreen(
Icon(Icons.Default.GroupAdd, contentDescription = stringResource(Res.string.properties_join_title))
}
}
IconButton(onClick = onNavigateToProfile) {
Icon(Icons.Default.AccountCircle, contentDescription = stringResource(Res.string.profile_title))
}
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = stringResource(Res.string.home_logout))
// Add property button
if (!isBlocked.allowed) {
IconButton(onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
}) {
Icon(
Icons.Default.AddCircle,
contentDescription = stringResource(Res.string.properties_add_button),
tint = MaterialTheme.colorScheme.primary
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(

View File

@@ -0,0 +1,17 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
/**
* iOS implementation is a no-op - import is handled in Swift layer via ContractorSharingManager.swift.
* The iOS iOSApp.swift handles file imports directly.
*/
@Composable
actual fun ContractorImportHandler(
pendingContractorImportUri: Any?,
onClearContractorImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit
) {
// No-op on iOS - import handled in Swift layer
}

View File

@@ -0,0 +1,15 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
/**
* iOS implementation is a no-op - sharing is handled in Swift layer via ContractorSharingManager.swift.
* The iOS ContractorDetailView uses the Swift sharing manager directly.
*/
@Composable
actual fun rememberShareContractor(): (Contractor) -> Unit {
return { _: Contractor ->
// No-op on iOS - sharing handled in Swift layer
}
}

View File

@@ -0,0 +1,13 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
@Composable
actual fun ContractorImportHandler(
pendingContractorImportUri: Any?,
onClearContractorImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit
) {
// Not implemented for JS/Web
}

View File

@@ -0,0 +1,11 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
@Composable
actual fun rememberShareContractor(): (Contractor) -> Unit {
return { _: Contractor ->
// Not implemented for JS/Web
}
}

View File

@@ -0,0 +1,13 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
@Composable
actual fun ContractorImportHandler(
pendingContractorImportUri: Any?,
onClearContractorImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit
) {
// Not implemented for JVM desktop
}

View File

@@ -0,0 +1,11 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
@Composable
actual fun rememberShareContractor(): (Contractor) -> Unit {
return { _: Contractor ->
// Not implemented for JVM desktop
}
}

View File

@@ -0,0 +1,13 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
@Composable
actual fun ContractorImportHandler(
pendingContractorImportUri: Any?,
onClearContractorImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit
) {
// Not implemented for Wasm/Web
}

View File

@@ -0,0 +1,11 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
import com.example.casera.models.Contractor
@Composable
actual fun rememberShareContractor(): (Contractor) -> Unit {
return { _: Contractor ->
// Not implemented for Wasm/Web
}
}

View File

@@ -0,0 +1,125 @@
## Weekly Tasks (10)
| # | Task | Category | Description |
|---|------|----------|-------------|
| 3 | Wipe kitchen counters | Cleaning | Clean countertops and stovetop after cooking |
| 5 | Take out trash | Cleaning | Empty full trash cans to prevent odors and pests |
| 6 | Vacuum floors | Cleaning | Vacuum all carpets and rugs, especially high-traffic areas |
| 7 | Mop hard floors | Cleaning | Mop tile, hardwood, and laminate floors |
| 8 | Clean bathrooms | Cleaning | Scrub toilets, sinks, showers, and mirrors |
| 9 | Change bed linens | Cleaning | Wash and replace sheets, pillowcases, and mattress covers |
| 10 | Do laundry | Cleaning | Wash, dry, fold, and put away clothes |
| 11 | Clean kitchen appliances | Cleaning | Wipe down microwave, dishwasher exterior, coffee maker |
| 12 | Dust surfaces | Cleaning | Dust furniture, shelves, and decorations |
| 13 | Clean out refrigerator | Cleaning | Discard expired food and wipe down shelves |
| 14 | Water indoor plants | Landscaping | Check soil moisture and water as needed |
| 15 | Check/charge security cameras | Safety | Ensure wireless cameras are functioning and charged |
Check and/or replace water heater anode rod
Test interior water shutoffs
Test gas shutoffs
Test water meter shutoff
Check water meter for leaks
Run drain cleaner
Clean vacuum
Clean microwave
Clean and reverse ceiling fans (fall/spring)
Clean floor registers
Clean toaster
Mop floors 1/2
Clean bathroom exhaust fans
Clean garbage disposal
Flush HVAC drain lines
Test smoke and carbon monoxide detectors
Clean return vents
Test water heater pressure relief valve
Clean ovens
Clean fridge compressor coils
Clean dishwasher food trap
Mop floors 2/2
Check fire extinguishers
Replace water filters
Clear HVAC drain lines
Check water meter for leaks
Clean HVAC compressor coils
Test water sensors
Schedule chimney cleaning
Test GFCIs
Schedule HVAC inspection and service
Replace fridge hose
Replace smoke and carbon monoxide detectors
Replace laundry hoses
---
## Monthly Tasks (10)
| # | Task | Category | Description |
|---|------|----------|-------------|
| 16 | Change HVAC filters | HVAC | Replace air conditioning/furnace filters for efficiency |
| 17 | Test smoke detectors | Safety | Press test button on all smoke and CO detectors |
| 18 | Clean garbage disposal | Appliances | Run ice cubes and lemon peels to clean and deodorize |
| 19 | Inspect for leaks | Plumbing | Check under sinks and around toilets for water damage |
| 20 | Clean vent hood filters | Appliances | Soak and scrub range hood filters to remove grease |
| 21 | Vacuum under furniture | Cleaning | Move furniture to vacuum underneath, especially beds |
| 22 | Clean inside trash cans | Cleaning | Wash and disinfect garbage and recycling bins |
| 23 | Inspect caulking | Plumbing | Check bathroom and kitchen caulk for cracks or mold |
| 24 | Weed garden beds | Landscaping | Remove weeds and prune plants as needed |
| 25 | Check tire pressure | Vehicle | Inspect vehicle tires and refill as needed |
---
## Quarterly Tasks (8)
| # | Task | Category | Description |
|---|------|----------|-------------|
| 26 | Deep clean oven | Appliances | Clean inside oven; remove baked-on grease and spills |
| 27 | Clean refrigerator coils | Appliances | Vacuum dust from condenser coils for efficiency |
| 28 | Test GFCI outlets | Electrical | Press test/reset buttons on bathroom and kitchen outlets |
| 29 | Flush water heater | Plumbing | Drain sediment from bottom of tank |
| 30 | Clean dishwasher | Appliances | Run empty cycle with cleaner; clean filter and door seals |
| 31 | Inspect fire extinguishers | Safety | Check pressure gauge and ensure accessibility |
| 32 | Clean window tracks | Cleaning | Remove dirt and debris from window and door tracks |
| 33 | Pest control treatment | Safety | Inspect for signs of pests; treat or call professional |
---
## Semi-Annual Tasks (7)
| # | Task | Category | Description |
|---|------|----------|-------------|
| 34 | Clean gutters | Exterior | Remove leaves and debris; check for proper drainage |
| 35 | HVAC professional service | HVAC | Have system inspected before heating/cooling seasons |
| 36 | Clean dryer vent | Appliances | Remove lint buildup to prevent fires (15,500 fires/year) |
| 37 | Wash windows | Exterior | Clean interior and exterior glass and screens |
| 38 | Inspect roof | Exterior | Look for missing shingles, damage, or debris |
| 39 | Deep clean carpets | Cleaning | Professional carpet cleaning or DIY steam clean |
| 40 | Replace batteries | Safety | Replace smoke/CO detector batteries (if not hardwired) |
---
## Annual Tasks (10)
| # | Task | Category | Description |
|---|------|----------|-------------|
| 41 | Chimney/fireplace inspection | Safety | Professional inspection before first use of season |
| 42 | Septic tank inspection | Plumbing | Have septic system inspected and pumped if needed |
| 43 | Termite inspection | Safety | Professional inspection for wood-destroying insects |
| 44 | Service garage door | Exterior | Lubricate springs, hinges, and rollers |
| 45 | Inspect weather stripping | Exterior | Check doors and windows; replace worn seals |
| 46 | Winterize outdoor faucets | Plumbing | Shut off water supply and drain lines before freeze |
| 47 | Pressure wash exterior | Exterior | Clean siding, driveway, sidewalks, and deck |
| 48 | Touch up exterior paint | Exterior | Address peeling or cracking paint to prevent moisture damage |
| 49 | Service sprinkler system | Landscaping | Inspect heads, adjust coverage, winterize if needed |
| 50 | Replace washing machine hoses | Appliances | Replace rubber hoses to prevent flooding |
## Sources
- [Care.com - Ultimate Household Chore List](https://www.care.com/c/ultimate-household-chore-list/)
- [Bungalow - Complete Household Chores List](https://bungalow.com/articles/the-complete-household-chores-list)
- [AHIT - Home Maintenance Checklist](https://www.ahit.com/home-inspection-career-guide/home-maintenance-checklist/)
- [Homebuyer.com - Home Maintenance Checklist](https://homebuyer.com/learn/home-maintenance-checklist)
- [Frontdoor - Home Maintenance Checklist](https://www.frontdoor.com/blog/handyman-tips/ultimate-home-maintenance-checklist)
- [Travelers Insurance - Monthly Home Maintenance](https://www.travelers.com/resources/home/maintenance/home-maintenance-checklist-10-easy-things-to-do-monthly)
- [American Family Insurance - Home Maintenance](https://www.amfam.com/resources/articles/at-home/home-maintenance-checklist)

View File

@@ -11,6 +11,8 @@ struct ContractorDetailView: View {
@State private var showingEditSheet = false
@State private var showingDeleteAlert = false
@State private var showingShareSheet = false
@State private var shareFileURL: URL?
var body: some View {
ZStack {
@@ -25,6 +27,10 @@ struct ContractorDetailView: View {
ToolbarItem(placement: .navigationBarTrailing) {
if let contractor = viewModel.selectedContractor {
Menu {
Button(action: { shareContractor(contractor) }) {
Label(L10n.Common.share, systemImage: "square.and.arrow.up")
}
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
viewModel.loadContractorDetail(id: contractorId)
}}) {
@@ -50,6 +56,11 @@ struct ContractorDetailView: View {
}
}
}
.sheet(isPresented: $showingShareSheet) {
if let url = shareFileURL {
ShareSheet(activityItems: [url])
}
}
.sheet(isPresented: $showingEditSheet) {
ContractorFormSheet(
contractor: viewModel.selectedContractor,
@@ -88,6 +99,13 @@ struct ContractorDetailView: View {
}
}
private func shareContractor(_ contractor: Contractor) {
if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) {
shareFileURL = url
showingShareSheet = true
}
}
// MARK: - Content State View
@ViewBuilder

View File

@@ -0,0 +1,240 @@
import Foundation
import ComposeApp
/// Manages contractor export and import via .casera files.
/// Singleton that handles file creation for sharing and parsing for import.
@MainActor
class ContractorSharingManager: ObservableObject {
// MARK: - Singleton
static let shared = ContractorSharingManager()
// MARK: - Published Properties
@Published var isImporting: Bool = false
@Published var importError: String?
@Published var importSuccess: Bool = false
@Published var importedContractorName: String?
// MARK: - Private Properties
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
private let jsonDecoder = JSONDecoder()
private init() {}
// MARK: - Export
/// Creates a shareable .casera file for a contractor.
/// - Parameter contractor: The contractor to export
/// - Returns: URL to the temporary file, or nil if creation failed
func createShareableFile(contractor: Contractor) -> URL? {
// Get current username for export metadata
let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown"
// Convert Contractor to SharedContractor using Kotlin extension
let sharedContractor = contractor.toSharedContractor(exportedBy: currentUsername)
// Create Swift-compatible structure for JSON encoding
let exportData = SharedContractorExport(from: sharedContractor)
guard let jsonData = try? jsonEncoder.encode(exportData) else {
print("ContractorSharingManager: Failed to encode contractor to JSON")
return nil
}
// Create a safe filename
let safeName = contractor.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("ContractorSharingManager: Failed to write .casera file: \(error)")
return nil
}
}
// MARK: - Import
/// Imports a contractor from a .casera file URL.
/// - Parameters:
/// - url: The URL to the .casera file
/// - completion: Called with true on success, false on failure
func importContractor(from url: URL, completion: @escaping (Bool) -> Void) {
isImporting = true
importError = nil
// Verify user is authenticated
guard TokenStorage.shared.getToken() != nil else {
importError = "You must be logged in to import a contractor"
isImporting = false
completion(false)
return
}
// Start accessing security-scoped resource if needed (for files from Files app)
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let data = try Data(contentsOf: url)
let exportData = try jsonDecoder.decode(SharedContractorExport.self, from: data)
// Resolve specialty names to IDs
let specialties = DataManagerObservable.shared.contractorSpecialties
let specialtyIds = exportData.resolveSpecialtyIds(availableSpecialties: specialties)
// Create the request
let createRequest = exportData.toCreateRequest(specialtyIds: specialtyIds)
// Call API to create contractor
Task {
do {
let result = try await APILayer.shared.createContractor(request: createRequest)
if let success = result as? ApiResultSuccess<Contractor>,
let contractor = success.data {
self.importedContractorName = contractor.name
self.importSuccess = true
self.isImporting = false
completion(true)
} else if let error = result as? ApiResultError {
self.importError = ErrorMessageParser.parse(error.message)
self.isImporting = false
completion(false)
} else {
self.importError = "Unknown error occurred"
self.isImporting = false
completion(false)
}
} catch {
self.importError = error.localizedDescription
self.isImporting = false
completion(false)
}
}
} catch {
importError = "Failed to read contractor file: \(error.localizedDescription)"
isImporting = false
completion(false)
}
}
/// Resets the import state after showing success/error feedback
func resetImportState() {
importError = nil
importSuccess = false
importedContractorName = nil
}
}
// MARK: - Swift Codable Structure
/// Swift-native Codable structure for .casera file format.
/// This mirrors the Kotlin SharedContractor model for JSON serialization.
struct SharedContractorExport: Codable {
let version: Int
let name: String
let company: String?
let phone: String?
let email: String?
let website: String?
let notes: String?
let streetAddress: String?
let city: String?
let stateProvince: String?
let postalCode: String?
let specialtyNames: [String]
let rating: Double?
let isFavorite: Bool
let exportedAt: String?
let exportedBy: String?
enum CodingKeys: String, CodingKey {
case version
case name
case company
case phone
case email
case website
case notes
case streetAddress = "street_address"
case city
case stateProvince = "state_province"
case postalCode = "postal_code"
case specialtyNames = "specialty_names"
case rating
case isFavorite = "is_favorite"
case exportedAt = "exported_at"
case exportedBy = "exported_by"
}
/// Initialize from Kotlin SharedContractor
init(from sharedContractor: SharedContractor) {
self.version = Int(sharedContractor.version)
self.name = sharedContractor.name
self.company = sharedContractor.company
self.phone = sharedContractor.phone
self.email = sharedContractor.email
self.website = sharedContractor.website
self.notes = sharedContractor.notes
self.streetAddress = sharedContractor.streetAddress
self.city = sharedContractor.city
self.stateProvince = sharedContractor.stateProvince
self.postalCode = sharedContractor.postalCode
self.specialtyNames = sharedContractor.specialtyNames
self.rating = sharedContractor.rating?.doubleValue
self.isFavorite = sharedContractor.isFavorite
self.exportedAt = sharedContractor.exportedAt
self.exportedBy = sharedContractor.exportedBy
}
/// Resolve specialty names to IDs using available specialties
func resolveSpecialtyIds(availableSpecialties: [ContractorSpecialty]) -> [Int32] {
return specialtyNames.compactMap { name in
availableSpecialties.first { specialty in
specialty.name.lowercased() == name.lowercased()
}?.id
}
}
/// Convert to ContractorCreateRequest for API call
func toCreateRequest(specialtyIds: [Int32]) -> ContractorCreateRequest {
let residenceIdValue: KotlinInt? = nil
let ratingValue: KotlinDouble? = rating.map { KotlinDouble(double: $0) }
let specialtyIdsValue: [KotlinInt]? = specialtyIds.isEmpty ? nil : specialtyIds.map { KotlinInt(int: $0) }
return ContractorCreateRequest(
name: name,
residenceId: residenceIdValue,
company: company,
phone: phone,
email: email,
website: website,
streetAddress: streetAddress,
city: city,
stateProvince: stateProvince,
postalCode: postalCode,
rating: ratingValue,
isFavorite: isFavorite,
notes: notes,
specialtyIds: specialtyIdsValue
)
}
}

View File

@@ -36,6 +36,7 @@ struct AccessibilityIdentifiers {
static let documentsTab = "TabBar.Documents"
static let profileTab = "TabBar.Profile"
static let backButton = "Navigation.BackButton"
static let settingsButton = "Navigation.SettingsButton"
}
// MARK: - Residence

View File

@@ -582,6 +582,8 @@ enum L10n {
static var yes: String { String(localized: "common_yes") }
static var no: String { String(localized: "common_no") }
static var ok: String { String(localized: "common_ok") }
static var share: String { String(localized: "common_share") }
static var `import`: String { String(localized: "common_import") }
}
// MARK: - Errors

View File

@@ -39,5 +39,43 @@
<array>
<string>remote-notification</string>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Casera Contractor</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.casera.contractor</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.casera.contractor</string>
<key>UTTypeDescription</key>
<string>Casera Contractor</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.json</string>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>casera</string>
</array>
<key>public.mime-type</key>
<string>application/json</string>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -39,6 +39,10 @@
}
}
},
"%@ has been added to your contacts." : {
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
"isCommentAutoGenerated" : true
},
"%@, %@" : {
"comment" : "A city and state combination.",
"isCommentAutoGenerated" : true,
@@ -4747,6 +4751,17 @@
}
}
},
"common_import" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Import"
}
}
}
},
"common_loading" : {
"extractionState" : "manual",
"localizations" : {
@@ -5072,6 +5087,17 @@
}
}
},
"common_share" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Share"
}
}
}
},
"common_success" : {
"extractionState" : "manual",
"localizations" : {
@@ -5229,6 +5255,9 @@
},
"Continue with Free" : {
},
"Contractor Imported" : {
},
"Contractors" : {
"comment" : "A tab label for the contractors section.",
@@ -17315,6 +17344,18 @@
}
}
},
"Import" : {
"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
},
"In Progress" : {
"comment" : "A label displayed next to an image of a play button, indicating that a task is currently in progress.",
"isCommentAutoGenerated" : true
@@ -17381,6 +17422,10 @@
"comment" : "A message displayed when no task templates match a search query.",
"isCommentAutoGenerated" : true
},
"OK" : {
"comment" : "A button that dismisses the success dialog.",
"isCommentAutoGenerated" : true
},
"or" : {
},
@@ -17406,10 +17451,6 @@
"comment" : "The title of the \"Pro\" plan in the feature comparison view.",
"isCommentAutoGenerated" : true
},
"Profile" : {
"comment" : "A label for the \"Profile\" tab in the main tab view.",
"isCommentAutoGenerated" : true
},
"profile_account" : {
"extractionState" : "manual",
"localizations" : {
@@ -29636,6 +29677,10 @@
"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.",
"isCommentAutoGenerated" : true
},
"You now have full access to all Pro features!" : {
"comment" : "A message displayed to users after successfully upgrading to the Pro version of the app.",
"isCommentAutoGenerated" : true

View File

@@ -47,16 +47,6 @@ struct MainTabView: View {
}
.tag(3)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
NavigationView {
ProfileTabView()
}
.id(refreshID)
.tabItem {
Label("Profile", systemImage: "person.fill")
}
.tag(4)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.profileTab)
}
.tint(Color.appPrimary)
.onChange(of: authManager.isAuthenticated) { _ in

View File

@@ -5,10 +5,11 @@ struct ManageUsersView: View {
let residenceId: Int32
let residenceName: String
let isPrimaryOwner: Bool
let residenceOwnerId: Int32
@Environment(\.dismiss) private var dismiss
@State private var users: [ResidenceUserResponse] = []
@State private var ownerId: Int32?
private var ownerId: Int32 { residenceOwnerId }
@State private var shareCode: ShareCodeResponse?
@State private var isLoading = true
@State private var errorMessage: String?
@@ -97,10 +98,9 @@ struct ManageUsersView: View {
let result = try await APILayer.shared.getResidenceUsers(residenceId: Int32(Int(residenceId)))
await MainActor.run {
if let successResult = result as? ApiResultSuccess<ResidenceUsersResponse>,
let responseData = successResult.data as? ResidenceUsersResponse {
self.users = Array(responseData.users)
self.ownerId = Int32(responseData.owner.id)
if let successResult = result as? ApiResultSuccess<NSArray>,
let responseData = successResult.data as? [ResidenceUserResponse] {
self.users = responseData
self.isLoading = false
} else if let errorResult = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
@@ -148,8 +148,9 @@ struct ManageUsersView: View {
let result = try await APILayer.shared.generateShareCode(residenceId: Int32(Int(residenceId)))
await MainActor.run {
if let successResult = result as? ApiResultSuccess<ShareCodeResponse> {
self.shareCode = successResult.data
if let successResult = result as? ApiResultSuccess<GenerateShareCodeResponse>,
let response = successResult.data {
self.shareCode = response.shareCode
self.isGeneratingCode = false
} else if let errorResult = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
@@ -195,5 +196,5 @@ struct ManageUsersView: View {
}
#Preview {
ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true)
ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true, residenceOwnerId: 1)
}

View File

@@ -120,7 +120,8 @@ struct ResidenceDetailView: View {
ManageUsersView(
residenceId: residence.id,
residenceName: residence.name,
isPrimaryOwner: isCurrentUserOwner(of: residence)
isPrimaryOwner: isCurrentUserOwner(of: residence),
residenceOwnerId: residence.ownerId
)
}
}

View File

@@ -6,6 +6,7 @@ struct ResidencesListView: View {
@State private var showingAddResidence = false
@State private var showingJoinResidence = false
@State private var showingUpgradePrompt = false
@State private var showingSettings = false
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@@ -46,6 +47,17 @@ struct ResidencesListView: View {
.navigationTitle(L10n.Residences.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
showingSettings = true
}) {
Image(systemName: "gearshape.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: {
// Check if we should show upgrade prompt before joining
@@ -93,6 +105,11 @@ struct ResidencesListView: View {
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
}
.sheet(isPresented: $showingSettings) {
NavigationView {
ProfileTabView()
}
}
.onAppear {
if authManager.isAuthenticated {
viewModel.loadMyResidences()

View File

@@ -5,8 +5,11 @@ import ComposeApp
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var themeManager = ThemeManager.shared
@StateObject private var sharingManager = ContractorSharingManager.shared
@Environment(\.scenePhase) private var scenePhase
@State private var deepLinkResetToken: String?
@State private var pendingImportURL: URL?
@State private var showImportConfirmation: Bool = false
init() {
// Initialize DataManager with platform-specific managers
@@ -33,8 +36,9 @@ struct iOSApp: App {
WindowGroup {
RootView()
.environmentObject(themeManager)
.environmentObject(sharingManager)
.onOpenURL { url in
handleDeepLink(url: url)
handleIncomingURL(url: url)
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
@@ -42,17 +46,84 @@ struct iOSApp: App {
PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded()
}
}
// Import confirmation dialog
.alert("Import Contractor", isPresented: $showImportConfirmation) {
Button("Import") {
if let url = pendingImportURL {
sharingManager.importContractor(from: url) { _ in
pendingImportURL = nil
}
}
}
Button("Cancel", role: .cancel) {
pendingImportURL = nil
}
} message: {
Text("Would you like to import this contractor to your contacts?")
}
// Import success dialog
.alert("Contractor Imported", isPresented: $sharingManager.importSuccess) {
Button("OK") {
sharingManager.resetImportState()
}
} message: {
Text("\(sharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
}
// Import error dialog
.alert("Import Failed", isPresented: .init(
get: { sharingManager.importError != nil },
set: { if !$0 { sharingManager.resetImportState() } }
)) {
Button("OK") {
sharingManager.resetImportState()
}
} message: {
Text(sharingManager.importError ?? "An error occurred while importing the contractor.")
}
}
}
// MARK: - Deep Link Handling
private func handleDeepLink(url: URL) {
print("Deep link received: \(url)")
// MARK: - URL Handling
/// Handles all incoming URLs - both deep links and file opens
private func handleIncomingURL(url: URL) {
print("URL received: \(url)")
// Handle .casera file imports
if url.pathExtension.lowercased() == "casera" {
handleContractorImport(url: url)
return
}
// Handle casera:// deep links
if url.scheme == "casera" {
handleDeepLink(url: url)
return
}
print("Unrecognized URL: \(url)")
}
/// Handles .casera file imports
private func handleContractorImport(url: URL) {
print("Contractor 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"
return
}
// Store URL and show confirmation dialog
pendingImportURL = url
showImportConfirmation = true
}
/// Handles casera:// deep links
private func handleDeepLink(url: URL) {
// Handle casera://reset-password?token=xxx
guard url.scheme == "casera",
url.host == "reset-password" else {
print("Unrecognized deep link scheme or host")
guard url.host == "reset-password" else {
print("Unrecognized deep link host: \(url.host ?? "nil")")
return
}