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:
@@ -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 -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -232,6 +232,7 @@ fun ResidenceDetailScreen(
|
||||
residenceId = residence.id,
|
||||
residenceName = residence.name,
|
||||
isPrimaryOwner = residence.ownerId == currentUser?.id,
|
||||
residenceOwnerId = residence.ownerId,
|
||||
onDismiss = {
|
||||
showManageUsersDialog = false
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
125
docs/TOP_50_HOUSEHOLD_TASKS.md
Normal file
125
docs/TOP_50_HOUSEHOLD_TASKS.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
240
iosApp/iosApp/Contractor/ContractorSharingManager.swift
Normal file
240
iosApp/iosApp/Contractor/ContractorSharingManager.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
0
iosApp/iosApp/Localizable.xcstrings.backup
Normal file
0
iosApp/iosApp/Localizable.xcstrings.backup
Normal 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user