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:scheme="casera"
|
||||||
android:host="reset-password" />
|
android:host="reset-password" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<!-- FileProvider for camera photos -->
|
<!-- 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.ui.theme.ThemeManager
|
||||||
import com.example.casera.fcm.FCMManager
|
import com.example.casera.fcm.FCMManager
|
||||||
import com.example.casera.platform.BillingManager
|
import com.example.casera.platform.BillingManager
|
||||||
|
import com.example.casera.network.APILayer
|
||||||
|
import com.example.casera.sharing.ContractorSharingManager
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||||
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||||
|
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
||||||
private lateinit var billingManager: BillingManager
|
private lateinit var billingManager: BillingManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -55,9 +58,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
// Initialize BillingManager for subscription management
|
// Initialize BillingManager for subscription management
|
||||||
billingManager = BillingManager.getInstance(applicationContext)
|
billingManager = BillingManager.getInstance(applicationContext)
|
||||||
|
|
||||||
// Handle deep link and notification navigation from intent
|
// Handle deep link, notification navigation, and file import from intent
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
handleNotificationNavigation(intent)
|
handleNotificationNavigation(intent)
|
||||||
|
handleFileImport(intent)
|
||||||
|
|
||||||
// Request notification permission and setup FCM
|
// Request notification permission and setup FCM
|
||||||
setupFCM()
|
setupFCM()
|
||||||
@@ -74,6 +78,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
navigateToTaskId = navigateToTaskId,
|
navigateToTaskId = navigateToTaskId,
|
||||||
onClearNavigateToTask = {
|
onClearNavigateToTask = {
|
||||||
navigateToTaskId = null
|
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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
handleNotificationNavigation(intent)
|
handleNotificationNavigation(intent)
|
||||||
|
handleFileImport(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNotificationNavigation(intent: 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 {
|
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.components {
|
.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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<cache-path name="camera_images" path="/" />
|
<cache-path name="camera_images" path="/" />
|
||||||
|
<cache-path name="shared_contractors" path="shared/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -246,6 +246,13 @@
|
|||||||
<string name="contractors_delete">Delete Contractor</string>
|
<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_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_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 -->
|
<!-- Documents -->
|
||||||
<string name="documents_title">Documents</string>
|
<string name="documents_title">Documents</string>
|
||||||
@@ -423,6 +430,9 @@
|
|||||||
<string name="common_yes">Yes</string>
|
<string name="common_yes">Yes</string>
|
||||||
<string name="common_no">No</string>
|
<string name="common_no">No</string>
|
||||||
<string name="common_ok">OK</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 -->
|
<!-- Errors -->
|
||||||
<string name="error_generic">Something went wrong. Please try again.</string>
|
<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.network.AuthApi
|
||||||
import com.example.casera.data.DataManager
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.network.APILayer
|
import com.example.casera.network.APILayer
|
||||||
|
import com.example.casera.platform.ContractorImportHandler
|
||||||
|
|
||||||
import casera.composeapp.generated.resources.Res
|
import casera.composeapp.generated.resources.Res
|
||||||
import casera.composeapp.generated.resources.compose_multiplatform
|
import casera.composeapp.generated.resources.compose_multiplatform
|
||||||
@@ -67,7 +68,9 @@ fun App(
|
|||||||
deepLinkResetToken: String? = null,
|
deepLinkResetToken: String? = null,
|
||||||
onClearDeepLinkToken: () -> Unit = {},
|
onClearDeepLinkToken: () -> Unit = {},
|
||||||
navigateToTaskId: Int? = null,
|
navigateToTaskId: Int? = null,
|
||||||
onClearNavigateToTask: () -> Unit = {}
|
onClearNavigateToTask: () -> Unit = {},
|
||||||
|
pendingContractorImportUri: Any? = null,
|
||||||
|
onClearContractorImport: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||||
var isVerified by remember { mutableStateOf(false) }
|
var isVerified by remember { mutableStateOf(false) }
|
||||||
@@ -110,6 +113,12 @@ fun App(
|
|||||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||||
|
|
||||||
MyCribTheme(themeColors = currentTheme) {
|
MyCribTheme(themeColors = currentTheme) {
|
||||||
|
// Handle contractor file imports (Android-specific, no-op on other platforms)
|
||||||
|
ContractorImportHandler(
|
||||||
|
pendingContractorImportUri = pendingContractorImportUri,
|
||||||
|
onClearContractorImport = onClearContractorImport
|
||||||
|
)
|
||||||
|
|
||||||
if (isCheckingAuth) {
|
if (isCheckingAuth) {
|
||||||
// Show loading screen while checking auth
|
// Show loading screen while checking auth
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -205,6 +205,11 @@ object DataManager {
|
|||||||
private val _lastSyncTime = MutableStateFlow(0L)
|
private val _lastSyncTime = MutableStateFlow(0L)
|
||||||
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
|
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== SEEDED DATA ETAG ====================
|
||||||
|
|
||||||
|
private val _seededDataETag = MutableStateFlow<String?>(null)
|
||||||
|
val seededDataETag: StateFlow<String?> = _seededDataETag.asStateFlow()
|
||||||
|
|
||||||
// ==================== INITIALIZATION ====================
|
// ==================== INITIALIZATION ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -584,6 +589,34 @@ object DataManager {
|
|||||||
_lookupsInitialized.value = true
|
_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() {
|
fun markLookupsInitialized() {
|
||||||
_lookupsInitialized.value = true
|
_lookupsInitialized.value = true
|
||||||
}
|
}
|
||||||
@@ -632,6 +665,7 @@ object DataManager {
|
|||||||
_taskTemplates.value = emptyList()
|
_taskTemplates.value = emptyList()
|
||||||
_taskTemplatesGrouped.value = null
|
_taskTemplatesGrouped.value = null
|
||||||
_lookupsInitialized.value = false
|
_lookupsInitialized.value = false
|
||||||
|
_seededDataETag.value = null
|
||||||
|
|
||||||
// Clear cache timestamps
|
// Clear cache timestamps
|
||||||
residencesCacheTime = 0L
|
residencesCacheTime = 0L
|
||||||
@@ -723,6 +757,11 @@ object DataManager {
|
|||||||
manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data ->
|
manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data ->
|
||||||
_hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false
|
_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) {
|
} catch (e: Exception) {
|
||||||
println("DataManager: Error loading from disk: ${e.message}")
|
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_CURRENT_USER = "dm_current_user"
|
||||||
private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding"
|
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>
|
@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
|
// Legacy wrapper responses for backward compatibility
|
||||||
// These can be removed once all code is migrated to use arrays directly
|
// 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
|
typealias ResidenceUsersResponse = List<ResidenceUserResponse>
|
||||||
data class ResidenceUsersResponse(
|
|
||||||
val owner: ResidenceUserResponse,
|
|
||||||
val users: List<ResidenceUserResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove user response
|
* 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.
|
* 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.
|
* 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)
|
* - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required)
|
||||||
* - /subscription/status/ requires auth and is only called if user is authenticated
|
* - /subscription/status/ requires auth and is only called if user is authenticated
|
||||||
*/
|
*/
|
||||||
suspend fun initializeLookups(): ApiResult<Unit> {
|
suspend fun initializeLookups(): ApiResult<Unit> {
|
||||||
val token = getToken()
|
val token = getToken()
|
||||||
|
val currentETag = DataManager.seededDataETag.value
|
||||||
|
|
||||||
if (DataManager.lookupsInitialized.value) {
|
// If lookups are already initialized and we have an ETag, do conditional fetch
|
||||||
// Lookups already initialized, but refresh subscription status if authenticated
|
if (DataManager.lookupsInitialized.value && currentETag != null) {
|
||||||
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
|
println("📋 [APILayer] Lookups initialized, checking for updates with ETag...")
|
||||||
if (token != null) {
|
return refreshLookupsIfChanged()
|
||||||
refreshSubscriptionStatus()
|
|
||||||
}
|
|
||||||
return ApiResult.Success(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required)
|
// Use seeded data endpoint with ETag support (PUBLIC - no auth required)
|
||||||
println("🔄 Fetching static data (all lookups)...")
|
println("🔄 Fetching seeded data (all lookups + templates)...")
|
||||||
val staticDataResult = lookupsApi.getStaticData(token) // token is optional
|
val seededDataResult = lookupsApi.getSeededData(currentETag, token)
|
||||||
println("📦 Static data result: $staticDataResult")
|
println("📦 Seeded data result: $seededDataResult")
|
||||||
|
|
||||||
// Update DataManager with all lookups at once
|
when (seededDataResult) {
|
||||||
if (staticDataResult is ApiResult.Success) {
|
is ConditionalResult.Success -> {
|
||||||
DataManager.setAllLookups(staticDataResult.data)
|
println("✅ Seeded data loaded successfully")
|
||||||
println("✅ All lookups loaded successfully")
|
DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag)
|
||||||
} else if (staticDataResult is ApiResult.Error) {
|
}
|
||||||
println("❌ Failed to fetch static data: ${staticDataResult.message}")
|
is ConditionalResult.NotModified -> {
|
||||||
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
|
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)
|
// Load upgrade triggers (PUBLIC - no auth required)
|
||||||
println("🔄 Fetching upgrade triggers...")
|
println("🔄 Fetching upgrade triggers...")
|
||||||
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional
|
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
|
||||||
println("📦 Upgrade triggers result: $upgradeTriggersResult")
|
println("📦 Upgrade triggers result: $upgradeTriggersResult")
|
||||||
|
|
||||||
if (upgradeTriggersResult is ApiResult.Success) {
|
if (upgradeTriggersResult is ApiResult.Success) {
|
||||||
@@ -122,20 +130,6 @@ object APILayer {
|
|||||||
println("⏭️ Skipping subscription status (not authenticated)")
|
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()
|
DataManager.markLookupsInitialized()
|
||||||
return ApiResult.Success(Unit)
|
return ApiResult.Success(Unit)
|
||||||
} catch (e: Exception) {
|
} 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.
|
* Get residence types from DataManager. If cache is empty, fetch from API.
|
||||||
*/
|
*/
|
||||||
@@ -893,95 +949,98 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Task Template Operations ====================
|
// ==================== 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.
|
* Get all task templates from DataManager.
|
||||||
* Task templates are PUBLIC (no auth required).
|
* 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>> {
|
suspend fun getTaskTemplates(forceRefresh: Boolean = false): ApiResult<List<TaskTemplate>> {
|
||||||
if (!forceRefresh) {
|
if (forceRefresh) {
|
||||||
val cached = DataManager.taskTemplates.value
|
// Force refresh via seeded data endpoint (includes templates)
|
||||||
if (cached.isNotEmpty()) {
|
refreshLookupsIfChanged()
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = taskTemplateApi.getTemplates()
|
val cached = DataManager.taskTemplates.value
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
if (result is ApiResult.Success) {
|
return ApiResult.Success(cached)
|
||||||
DataManager.setTaskTemplates(result.data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
* 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> {
|
suspend fun getTaskTemplatesGrouped(forceRefresh: Boolean = false): ApiResult<TaskTemplatesGroupedResponse> {
|
||||||
if (!forceRefresh) {
|
if (forceRefresh) {
|
||||||
val cached = DataManager.taskTemplatesGrouped.value
|
// Force refresh via seeded data endpoint (includes templates)
|
||||||
if (cached != null) {
|
refreshLookupsIfChanged()
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = taskTemplateApi.getTemplatesGrouped()
|
val cached = DataManager.taskTemplatesGrouped.value
|
||||||
|
|
||||||
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 }
|
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return ApiResult.Success(cached)
|
return ApiResult.Success(cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to API
|
// If still empty, initialize lookups (which includes templates via seeded data)
|
||||||
return taskTemplateApi.getTemplate(id)
|
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 ====================
|
// ==================== Auth Operations ====================
|
||||||
|
|||||||
@@ -4,8 +4,32 @@ import com.example.casera.models.*
|
|||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
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) {
|
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
private val baseUrl = ApiClient.getBaseUrl()
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
|
|
||||||
@@ -137,4 +161,47 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
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,
|
residenceId: Int,
|
||||||
residenceName: String,
|
residenceName: String,
|
||||||
isPrimaryOwner: Boolean,
|
isPrimaryOwner: Boolean,
|
||||||
|
residenceOwnerId: Int,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onUserRemoved: () -> Unit = {}
|
onUserRemoved: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
|
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 shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -46,8 +47,7 @@ fun ManageUsersDialog(
|
|||||||
if (token != null) {
|
if (token != null) {
|
||||||
when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
|
when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
users = result.data.users
|
users = result.data
|
||||||
ownerId = result.data.owner.id
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.example.casera.ui.components.HandleErrors
|
|||||||
import com.example.casera.util.DateUtils
|
import com.example.casera.util.DateUtils
|
||||||
import com.example.casera.viewmodel.ContractorViewModel
|
import com.example.casera.viewmodel.ContractorViewModel
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
|
import com.example.casera.platform.rememberShareContractor
|
||||||
import casera.composeapp.generated.resources.*
|
import casera.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ fun ContractorDetailScreen(
|
|||||||
var showEditDialog by remember { mutableStateOf(false) }
|
var showEditDialog by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val shareContractor = rememberShareContractor()
|
||||||
|
|
||||||
LaunchedEffect(contractorId) {
|
LaunchedEffect(contractorId) {
|
||||||
viewModel.loadContractorDetail(contractorId)
|
viewModel.loadContractorDetail(contractorId)
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,9 @@ fun ContractorDetailScreen(
|
|||||||
actions = {
|
actions = {
|
||||||
when (val state = contractorState) {
|
when (val state = contractorState) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
|
IconButton(onClick = { shareContractor(state.data) }) {
|
||||||
|
Icon(Icons.Default.Share, stringResource(Res.string.common_share))
|
||||||
|
}
|
||||||
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
|
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
|
||||||
Icon(
|
Icon(
|
||||||
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
|
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ fun ResidenceDetailScreen(
|
|||||||
residenceId = residence.id,
|
residenceId = residence.id,
|
||||||
residenceName = residence.name,
|
residenceName = residence.name,
|
||||||
isPrimaryOwner = residence.ownerId == currentUser?.id,
|
isPrimaryOwner = residence.ownerId == currentUser?.id,
|
||||||
|
residenceOwnerId = residence.ownerId,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showManageUsersDialog = false
|
showManageUsersDialog = false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ fun ResidencesScreen(
|
|||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateToProfile) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Settings,
|
||||||
|
contentDescription = stringResource(Res.string.profile_title),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Only show Join button if not blocked (limit>0)
|
// Only show Join button if not blocked (limit>0)
|
||||||
if (!isBlocked.allowed) {
|
if (!isBlocked.allowed) {
|
||||||
@@ -128,11 +137,23 @@ fun ResidencesScreen(
|
|||||||
Icon(Icons.Default.GroupAdd, contentDescription = stringResource(Res.string.properties_join_title))
|
Icon(Icons.Default.GroupAdd, contentDescription = stringResource(Res.string.properties_join_title))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onNavigateToProfile) {
|
// Add property button
|
||||||
Icon(Icons.Default.AccountCircle, contentDescription = stringResource(Res.string.profile_title))
|
if (!isBlocked.allowed) {
|
||||||
}
|
IconButton(onClick = {
|
||||||
IconButton(onClick = onLogout) {
|
val (allowed, triggerKey) = canAddProperty()
|
||||||
Icon(Icons.Default.ExitToApp, contentDescription = stringResource(Res.string.home_logout))
|
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(
|
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 showingEditSheet = false
|
||||||
@State private var showingDeleteAlert = false
|
@State private var showingDeleteAlert = false
|
||||||
|
@State private var showingShareSheet = false
|
||||||
|
@State private var shareFileURL: URL?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -25,6 +27,10 @@ struct ContractorDetailView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if let contractor = viewModel.selectedContractor {
|
if let contractor = viewModel.selectedContractor {
|
||||||
Menu {
|
Menu {
|
||||||
|
Button(action: { shareContractor(contractor) }) {
|
||||||
|
Label(L10n.Common.share, systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
|
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
|
||||||
viewModel.loadContractorDetail(id: contractorId)
|
viewModel.loadContractorDetail(id: contractorId)
|
||||||
}}) {
|
}}) {
|
||||||
@@ -50,6 +56,11 @@ struct ContractorDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingShareSheet) {
|
||||||
|
if let url = shareFileURL {
|
||||||
|
ShareSheet(activityItems: [url])
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingEditSheet) {
|
.sheet(isPresented: $showingEditSheet) {
|
||||||
ContractorFormSheet(
|
ContractorFormSheet(
|
||||||
contractor: viewModel.selectedContractor,
|
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
|
// MARK: - Content State View
|
||||||
|
|
||||||
@ViewBuilder
|
@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 documentsTab = "TabBar.Documents"
|
||||||
static let profileTab = "TabBar.Profile"
|
static let profileTab = "TabBar.Profile"
|
||||||
static let backButton = "Navigation.BackButton"
|
static let backButton = "Navigation.BackButton"
|
||||||
|
static let settingsButton = "Navigation.SettingsButton"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Residence
|
// MARK: - Residence
|
||||||
|
|||||||
@@ -582,6 +582,8 @@ enum L10n {
|
|||||||
static var yes: String { String(localized: "common_yes") }
|
static var yes: String { String(localized: "common_yes") }
|
||||||
static var no: String { String(localized: "common_no") }
|
static var no: String { String(localized: "common_no") }
|
||||||
static var ok: String { String(localized: "common_ok") }
|
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
|
// MARK: - Errors
|
||||||
|
|||||||
@@ -39,5 +39,43 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</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.",
|
"comment" : "A city and state combination.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -4747,6 +4751,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common_import" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Import"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"common_loading" : {
|
"common_loading" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5072,6 +5087,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common_share" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"common_success" : {
|
"common_success" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5229,6 +5255,9 @@
|
|||||||
},
|
},
|
||||||
"Continue with Free" : {
|
"Continue with Free" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Contractor Imported" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Contractors" : {
|
"Contractors" : {
|
||||||
"comment" : "A tab label for the contractors section.",
|
"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" : {
|
"In Progress" : {
|
||||||
"comment" : "A label displayed next to an image of a play button, indicating that a task is currently in progress.",
|
"comment" : "A label displayed next to an image of a play button, indicating that a task is currently in progress.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -17381,6 +17422,10 @@
|
|||||||
"comment" : "A message displayed when no task templates match a search query.",
|
"comment" : "A message displayed when no task templates match a search query.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"OK" : {
|
||||||
|
"comment" : "A button that dismisses the success dialog.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"or" : {
|
"or" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -17406,10 +17451,6 @@
|
|||||||
"comment" : "The title of the \"Pro\" plan in the feature comparison view.",
|
"comment" : "The title of the \"Pro\" plan in the feature comparison view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Profile" : {
|
|
||||||
"comment" : "A label for the \"Profile\" tab in the main tab view.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"profile_account" : {
|
"profile_account" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -29636,6 +29677,10 @@
|
|||||||
"comment" : "The title of the welcome screen in the preview.",
|
"comment" : "The title of the welcome screen in the preview.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Would you like to import this contractor to your contacts?" : {
|
||||||
|
"comment" : "A message displayed in an alert when a user imports a contractor.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"You now have full access to all Pro features!" : {
|
"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.",
|
"comment" : "A message displayed to users after successfully upgrading to the Pro version of the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"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)
|
.tag(3)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
||||||
|
|
||||||
NavigationView {
|
|
||||||
ProfileTabView()
|
|
||||||
}
|
|
||||||
.id(refreshID)
|
|
||||||
.tabItem {
|
|
||||||
Label("Profile", systemImage: "person.fill")
|
|
||||||
}
|
|
||||||
.tag(4)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.profileTab)
|
|
||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: authManager.isAuthenticated) { _ in
|
.onChange(of: authManager.isAuthenticated) { _ in
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ struct ManageUsersView: View {
|
|||||||
let residenceId: Int32
|
let residenceId: Int32
|
||||||
let residenceName: String
|
let residenceName: String
|
||||||
let isPrimaryOwner: Bool
|
let isPrimaryOwner: Bool
|
||||||
|
let residenceOwnerId: Int32
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var users: [ResidenceUserResponse] = []
|
@State private var users: [ResidenceUserResponse] = []
|
||||||
@State private var ownerId: Int32?
|
private var ownerId: Int32 { residenceOwnerId }
|
||||||
@State private var shareCode: ShareCodeResponse?
|
@State private var shareCode: ShareCodeResponse?
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@@ -97,10 +98,9 @@ struct ManageUsersView: View {
|
|||||||
let result = try await APILayer.shared.getResidenceUsers(residenceId: Int32(Int(residenceId)))
|
let result = try await APILayer.shared.getResidenceUsers(residenceId: Int32(Int(residenceId)))
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let successResult = result as? ApiResultSuccess<ResidenceUsersResponse>,
|
if let successResult = result as? ApiResultSuccess<NSArray>,
|
||||||
let responseData = successResult.data as? ResidenceUsersResponse {
|
let responseData = successResult.data as? [ResidenceUserResponse] {
|
||||||
self.users = Array(responseData.users)
|
self.users = responseData
|
||||||
self.ownerId = Int32(responseData.owner.id)
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
} else if let errorResult = result as? ApiResultError {
|
} else if let errorResult = result as? ApiResultError {
|
||||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||||
@@ -148,8 +148,9 @@ struct ManageUsersView: View {
|
|||||||
let result = try await APILayer.shared.generateShareCode(residenceId: Int32(Int(residenceId)))
|
let result = try await APILayer.shared.generateShareCode(residenceId: Int32(Int(residenceId)))
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let successResult = result as? ApiResultSuccess<ShareCodeResponse> {
|
if let successResult = result as? ApiResultSuccess<GenerateShareCodeResponse>,
|
||||||
self.shareCode = successResult.data
|
let response = successResult.data {
|
||||||
|
self.shareCode = response.shareCode
|
||||||
self.isGeneratingCode = false
|
self.isGeneratingCode = false
|
||||||
} else if let errorResult = result as? ApiResultError {
|
} else if let errorResult = result as? ApiResultError {
|
||||||
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
|
||||||
@@ -195,5 +196,5 @@ struct ManageUsersView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#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(
|
ManageUsersView(
|
||||||
residenceId: residence.id,
|
residenceId: residence.id,
|
||||||
residenceName: residence.name,
|
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 showingAddResidence = false
|
||||||
@State private var showingJoinResidence = false
|
@State private var showingJoinResidence = false
|
||||||
@State private var showingUpgradePrompt = false
|
@State private var showingUpgradePrompt = false
|
||||||
|
@State private var showingSettings = false
|
||||||
@StateObject private var authManager = AuthenticationManager.shared
|
@StateObject private var authManager = AuthenticationManager.shared
|
||||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||||
|
|
||||||
@@ -46,6 +47,17 @@ struct ResidencesListView: View {
|
|||||||
.navigationTitle(L10n.Residences.title)
|
.navigationTitle(L10n.Residences.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.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) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// Check if we should show upgrade prompt before joining
|
// Check if we should show upgrade prompt before joining
|
||||||
@@ -93,6 +105,11 @@ struct ResidencesListView: View {
|
|||||||
.sheet(isPresented: $showingUpgradePrompt) {
|
.sheet(isPresented: $showingUpgradePrompt) {
|
||||||
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
|
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingSettings) {
|
||||||
|
NavigationView {
|
||||||
|
ProfileTabView()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if authManager.isAuthenticated {
|
if authManager.isAuthenticated {
|
||||||
viewModel.loadMyResidences()
|
viewModel.loadMyResidences()
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import ComposeApp
|
|||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@StateObject private var themeManager = ThemeManager.shared
|
@StateObject private var themeManager = ThemeManager.shared
|
||||||
|
@StateObject private var sharingManager = ContractorSharingManager.shared
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var deepLinkResetToken: String?
|
@State private var deepLinkResetToken: String?
|
||||||
|
@State private var pendingImportURL: URL?
|
||||||
|
@State private var showImportConfirmation: Bool = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize DataManager with platform-specific managers
|
// Initialize DataManager with platform-specific managers
|
||||||
@@ -33,8 +36,9 @@ struct iOSApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environmentObject(themeManager)
|
.environmentObject(themeManager)
|
||||||
|
.environmentObject(sharingManager)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleDeepLink(url: url)
|
handleIncomingURL(url: url)
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
@@ -42,17 +46,84 @@ struct iOSApp: App {
|
|||||||
PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded()
|
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
|
// MARK: - URL Handling
|
||||||
private func handleDeepLink(url: URL) {
|
|
||||||
print("Deep link received: \(url)")
|
|
||||||
|
|
||||||
|
/// 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
|
// Handle casera://reset-password?token=xxx
|
||||||
guard url.scheme == "casera",
|
guard url.host == "reset-password" else {
|
||||||
url.host == "reset-password" else {
|
print("Unrecognized deep link host: \(url.host ?? "nil")")
|
||||||
print("Unrecognized deep link scheme or host")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user