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:
@@ -57,6 +57,7 @@ import com.example.casera.network.ApiResult
|
||||
import com.example.casera.network.AuthApi
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.network.APILayer
|
||||
import com.example.casera.platform.ContractorImportHandler
|
||||
|
||||
import casera.composeapp.generated.resources.Res
|
||||
import casera.composeapp.generated.resources.compose_multiplatform
|
||||
@@ -67,7 +68,9 @@ fun App(
|
||||
deepLinkResetToken: String? = null,
|
||||
onClearDeepLinkToken: () -> Unit = {},
|
||||
navigateToTaskId: Int? = null,
|
||||
onClearNavigateToTask: () -> Unit = {}
|
||||
onClearNavigateToTask: () -> Unit = {},
|
||||
pendingContractorImportUri: Any? = null,
|
||||
onClearContractorImport: () -> Unit = {}
|
||||
) {
|
||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||
var isVerified by remember { mutableStateOf(false) }
|
||||
@@ -110,6 +113,12 @@ fun App(
|
||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||
|
||||
MyCribTheme(themeColors = currentTheme) {
|
||||
// Handle contractor file imports (Android-specific, no-op on other platforms)
|
||||
ContractorImportHandler(
|
||||
pendingContractorImportUri = pendingContractorImportUri,
|
||||
onClearContractorImport = onClearContractorImport
|
||||
)
|
||||
|
||||
if (isCheckingAuth) {
|
||||
// Show loading screen while checking auth
|
||||
Surface(
|
||||
|
||||
@@ -205,6 +205,11 @@ object DataManager {
|
||||
private val _lastSyncTime = MutableStateFlow(0L)
|
||||
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
|
||||
|
||||
// ==================== SEEDED DATA ETAG ====================
|
||||
|
||||
private val _seededDataETag = MutableStateFlow<String?>(null)
|
||||
val seededDataETag: StateFlow<String?> = _seededDataETag.asStateFlow()
|
||||
|
||||
// ==================== INITIALIZATION ====================
|
||||
|
||||
/**
|
||||
@@ -584,6 +589,34 @@ object DataManager {
|
||||
_lookupsInitialized.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all lookups from unified seeded data response.
|
||||
* Also stores the ETag for future conditional requests.
|
||||
*/
|
||||
fun setAllLookupsFromSeededData(seededData: SeededDataResponse, etag: String?) {
|
||||
setResidenceTypes(seededData.residenceTypes)
|
||||
setTaskFrequencies(seededData.taskFrequencies)
|
||||
setTaskPriorities(seededData.taskPriorities)
|
||||
setTaskStatuses(seededData.taskStatuses)
|
||||
setTaskCategories(seededData.taskCategories)
|
||||
setContractorSpecialties(seededData.contractorSpecialties)
|
||||
setTaskTemplatesGrouped(seededData.taskTemplates)
|
||||
setSeededDataETag(etag)
|
||||
_lookupsInitialized.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ETag for seeded data. Used for conditional requests.
|
||||
*/
|
||||
fun setSeededDataETag(etag: String?) {
|
||||
_seededDataETag.value = etag
|
||||
if (etag != null) {
|
||||
persistenceManager?.save(KEY_SEEDED_DATA_ETAG, etag)
|
||||
} else {
|
||||
persistenceManager?.remove(KEY_SEEDED_DATA_ETAG)
|
||||
}
|
||||
}
|
||||
|
||||
fun markLookupsInitialized() {
|
||||
_lookupsInitialized.value = true
|
||||
}
|
||||
@@ -632,6 +665,7 @@ object DataManager {
|
||||
_taskTemplates.value = emptyList()
|
||||
_taskTemplatesGrouped.value = null
|
||||
_lookupsInitialized.value = false
|
||||
_seededDataETag.value = null
|
||||
|
||||
// Clear cache timestamps
|
||||
residencesCacheTime = 0L
|
||||
@@ -723,6 +757,11 @@ object DataManager {
|
||||
manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data ->
|
||||
_hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false
|
||||
}
|
||||
|
||||
// Load seeded data ETag for conditional requests
|
||||
manager.load(KEY_SEEDED_DATA_ETAG)?.let { data ->
|
||||
_seededDataETag.value = data
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataManager: Error loading from disk: ${e.message}")
|
||||
}
|
||||
@@ -733,4 +772,5 @@ object DataManager {
|
||||
|
||||
private const val KEY_CURRENT_USER = "dm_current_user"
|
||||
private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding"
|
||||
private const val KEY_SEEDED_DATA_ETAG = "dm_seeded_data_etag"
|
||||
}
|
||||
|
||||
@@ -109,6 +109,21 @@ data class StaticDataResponse(
|
||||
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified seeded data response - all lookups + task templates in one call
|
||||
* Supports ETag-based conditional fetching for efficient caching
|
||||
*/
|
||||
@Serializable
|
||||
data class SeededDataResponse(
|
||||
@SerialName("residence_types") val residenceTypes: List<ResidenceType>,
|
||||
@SerialName("task_categories") val taskCategories: List<TaskCategory>,
|
||||
@SerialName("task_priorities") val taskPriorities: List<TaskPriority>,
|
||||
@SerialName("task_frequencies") val taskFrequencies: List<TaskFrequency>,
|
||||
@SerialName("task_statuses") val taskStatuses: List<TaskStatus>,
|
||||
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>,
|
||||
@SerialName("task_templates") val taskTemplates: TaskTemplatesGroupedResponse
|
||||
)
|
||||
|
||||
// Legacy wrapper responses for backward compatibility
|
||||
// These can be removed once all code is migrated to use arrays directly
|
||||
|
||||
|
||||
@@ -232,13 +232,9 @@ data class ResidenceTaskSummary(
|
||||
)
|
||||
|
||||
/**
|
||||
* Residence users response
|
||||
* Residence users response - API returns a flat list of all users with access
|
||||
*/
|
||||
@Serializable
|
||||
data class ResidenceUsersResponse(
|
||||
val owner: ResidenceUserResponse,
|
||||
val users: List<ResidenceUserResponse>
|
||||
)
|
||||
typealias ResidenceUsersResponse = List<ResidenceUserResponse>
|
||||
|
||||
/**
|
||||
* Remove user response
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.example.casera.models
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Data model for .casera file format used to share contractors between users.
|
||||
* Contains only the data needed to recreate a contractor, without server-specific IDs.
|
||||
*/
|
||||
@Serializable
|
||||
data class SharedContractor(
|
||||
/** File format version for future compatibility */
|
||||
val version: Int = 1,
|
||||
|
||||
val name: String,
|
||||
val company: String? = null,
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val website: String? = null,
|
||||
val notes: String? = null,
|
||||
|
||||
@SerialName("street_address")
|
||||
val streetAddress: String? = null,
|
||||
val city: String? = null,
|
||||
@SerialName("state_province")
|
||||
val stateProvince: String? = null,
|
||||
@SerialName("postal_code")
|
||||
val postalCode: String? = null,
|
||||
|
||||
/** Specialty names (not IDs) for cross-account compatibility */
|
||||
@SerialName("specialty_names")
|
||||
val specialtyNames: List<String> = emptyList(),
|
||||
|
||||
val rating: Double? = null,
|
||||
@SerialName("is_favorite")
|
||||
val isFavorite: Boolean = false,
|
||||
|
||||
/** ISO8601 timestamp when the contractor was exported */
|
||||
@SerialName("exported_at")
|
||||
val exportedAt: String? = null,
|
||||
|
||||
/** Username of the person who exported the contractor */
|
||||
@SerialName("exported_by")
|
||||
val exportedBy: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Convert a full Contractor to SharedContractor for export.
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor {
|
||||
return SharedContractor(
|
||||
version = 1,
|
||||
name = name,
|
||||
company = company,
|
||||
phone = phone,
|
||||
email = email,
|
||||
website = website,
|
||||
notes = notes,
|
||||
streetAddress = streetAddress,
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
specialtyNames = specialties.map { it.name },
|
||||
rating = rating,
|
||||
isFavorite = isFavorite,
|
||||
exportedAt = Clock.System.now().toString(),
|
||||
exportedBy = exportedBy
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SharedContractor to ContractorCreateRequest for import.
|
||||
* @param specialtyIds The resolved specialty IDs from the importing account's lookup data
|
||||
*/
|
||||
fun SharedContractor.toCreateRequest(specialtyIds: List<Int>): ContractorCreateRequest {
|
||||
return ContractorCreateRequest(
|
||||
name = name,
|
||||
residenceId = null, // Imported contractors have no residence association
|
||||
company = company,
|
||||
phone = phone,
|
||||
email = email,
|
||||
website = website,
|
||||
streetAddress = streetAddress,
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
rating = rating,
|
||||
isFavorite = isFavorite,
|
||||
notes = notes,
|
||||
specialtyIds = specialtyIds.ifEmpty { null }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve specialty names to IDs using the available specialties in the importing account.
|
||||
* Case-insensitive matching.
|
||||
*/
|
||||
fun SharedContractor.resolveSpecialtyIds(availableSpecialties: List<ContractorSpecialty>): List<Int> {
|
||||
return specialtyNames.mapNotNull { name ->
|
||||
availableSpecialties.find { specialty ->
|
||||
specialty.name.equals(name, ignoreCase = true)
|
||||
}?.id
|
||||
}
|
||||
}
|
||||
@@ -62,39 +62,47 @@ object APILayer {
|
||||
* Initialize all lookup data. Can be called at app start even without authentication.
|
||||
* Loads all reference data (residence types, task categories, priorities, etc.) into DataManager.
|
||||
*
|
||||
* Uses ETag-based conditional fetching - if data hasn't changed on server, returns 304 Not Modified
|
||||
* and uses existing cached data. This is efficient for app foreground/resume scenarios.
|
||||
*
|
||||
* - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required)
|
||||
* - /subscription/status/ requires auth and is only called if user is authenticated
|
||||
*/
|
||||
suspend fun initializeLookups(): ApiResult<Unit> {
|
||||
val token = getToken()
|
||||
val currentETag = DataManager.seededDataETag.value
|
||||
|
||||
if (DataManager.lookupsInitialized.value) {
|
||||
// Lookups already initialized, but refresh subscription status if authenticated
|
||||
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
|
||||
if (token != null) {
|
||||
refreshSubscriptionStatus()
|
||||
}
|
||||
return ApiResult.Success(Unit)
|
||||
// If lookups are already initialized and we have an ETag, do conditional fetch
|
||||
if (DataManager.lookupsInitialized.value && currentETag != null) {
|
||||
println("📋 [APILayer] Lookups initialized, checking for updates with ETag...")
|
||||
return refreshLookupsIfChanged()
|
||||
}
|
||||
|
||||
try {
|
||||
// Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required)
|
||||
println("🔄 Fetching static data (all lookups)...")
|
||||
val staticDataResult = lookupsApi.getStaticData(token) // token is optional
|
||||
println("📦 Static data result: $staticDataResult")
|
||||
// Use seeded data endpoint with ETag support (PUBLIC - no auth required)
|
||||
println("🔄 Fetching seeded data (all lookups + templates)...")
|
||||
val seededDataResult = lookupsApi.getSeededData(currentETag, token)
|
||||
println("📦 Seeded data result: $seededDataResult")
|
||||
|
||||
// Update DataManager with all lookups at once
|
||||
if (staticDataResult is ApiResult.Success) {
|
||||
DataManager.setAllLookups(staticDataResult.data)
|
||||
println("✅ All lookups loaded successfully")
|
||||
} else if (staticDataResult is ApiResult.Error) {
|
||||
println("❌ Failed to fetch static data: ${staticDataResult.message}")
|
||||
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
|
||||
when (seededDataResult) {
|
||||
is ConditionalResult.Success -> {
|
||||
println("✅ Seeded data loaded successfully")
|
||||
DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag)
|
||||
}
|
||||
is ConditionalResult.NotModified -> {
|
||||
println("✅ Seeded data not modified, using cached data")
|
||||
DataManager.markLookupsInitialized()
|
||||
}
|
||||
is ConditionalResult.Error -> {
|
||||
println("❌ Failed to fetch seeded data: ${seededDataResult.message}")
|
||||
// Fallback to old static_data endpoint without task templates
|
||||
return fallbackToLegacyStaticData(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Load upgrade triggers (PUBLIC - no auth required)
|
||||
println("🔄 Fetching upgrade triggers...")
|
||||
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional
|
||||
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
|
||||
println("📦 Upgrade triggers result: $upgradeTriggersResult")
|
||||
|
||||
if (upgradeTriggersResult is ApiResult.Success) {
|
||||
@@ -122,20 +130,6 @@ object APILayer {
|
||||
println("⏭️ Skipping subscription status (not authenticated)")
|
||||
}
|
||||
|
||||
// Load task templates (PUBLIC - no auth required)
|
||||
println("🔄 Fetching task templates...")
|
||||
val templatesResult = taskTemplateApi.getTemplatesGrouped()
|
||||
println("📦 Task templates result: $templatesResult")
|
||||
|
||||
if (templatesResult is ApiResult.Success) {
|
||||
println("✅ Updating task templates with ${templatesResult.data.totalCount} templates")
|
||||
DataManager.setTaskTemplatesGrouped(templatesResult.data)
|
||||
println("✅ Task templates updated successfully")
|
||||
} else if (templatesResult is ApiResult.Error) {
|
||||
println("❌ Failed to fetch task templates: ${templatesResult.message}")
|
||||
// Non-fatal error - templates are optional for app functionality
|
||||
}
|
||||
|
||||
DataManager.markLookupsInitialized()
|
||||
return ApiResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
@@ -143,6 +137,68 @@ object APILayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh lookups only if data has changed on server (using ETag).
|
||||
* Called when app comes to foreground or resumes.
|
||||
* Returns quickly with 304 Not Modified if data hasn't changed.
|
||||
*/
|
||||
suspend fun refreshLookupsIfChanged(): ApiResult<Unit> {
|
||||
val token = getToken()
|
||||
val currentETag = DataManager.seededDataETag.value
|
||||
|
||||
println("🔄 [APILayer] Checking if lookups have changed (ETag: $currentETag)...")
|
||||
|
||||
val seededDataResult = lookupsApi.getSeededData(currentETag, token)
|
||||
|
||||
when (seededDataResult) {
|
||||
is ConditionalResult.Success -> {
|
||||
println("✅ Lookups have changed, updating DataManager")
|
||||
DataManager.setAllLookupsFromSeededData(seededDataResult.data, seededDataResult.etag)
|
||||
}
|
||||
is ConditionalResult.NotModified -> {
|
||||
println("✅ Lookups unchanged (304 Not Modified)")
|
||||
}
|
||||
is ConditionalResult.Error -> {
|
||||
println("❌ Failed to check lookup updates: ${seededDataResult.message}")
|
||||
// Non-fatal - continue using cached data
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh subscription status if authenticated
|
||||
if (token != null) {
|
||||
refreshSubscriptionStatus()
|
||||
}
|
||||
|
||||
return ApiResult.Success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to legacy static_data endpoint if seeded_data fails.
|
||||
* Does not include task templates.
|
||||
*/
|
||||
private suspend fun fallbackToLegacyStaticData(token: String?): ApiResult<Unit> {
|
||||
println("🔄 Falling back to legacy static data endpoint...")
|
||||
val staticDataResult = lookupsApi.getStaticData(token)
|
||||
|
||||
if (staticDataResult is ApiResult.Success) {
|
||||
DataManager.setAllLookups(staticDataResult.data)
|
||||
println("✅ Legacy static data loaded successfully")
|
||||
|
||||
// Try to load task templates separately
|
||||
val templatesResult = taskTemplateApi.getTemplatesGrouped()
|
||||
if (templatesResult is ApiResult.Success) {
|
||||
DataManager.setTaskTemplatesGrouped(templatesResult.data)
|
||||
}
|
||||
|
||||
DataManager.markLookupsInitialized()
|
||||
return ApiResult.Success(Unit)
|
||||
} else if (staticDataResult is ApiResult.Error) {
|
||||
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
|
||||
}
|
||||
|
||||
return ApiResult.Error("Unknown error loading lookups")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get residence types from DataManager. If cache is empty, fetch from API.
|
||||
*/
|
||||
@@ -893,95 +949,98 @@ object APILayer {
|
||||
}
|
||||
|
||||
// ==================== Task Template Operations ====================
|
||||
// Task templates are now included in seeded data, so these methods primarily use cache.
|
||||
// If forceRefresh is needed, use refreshLookupsIfChanged() to get fresh data from server.
|
||||
|
||||
/**
|
||||
* Get all task templates from DataManager. If cache is empty, fetch from API.
|
||||
* Task templates are PUBLIC (no auth required).
|
||||
* Get all task templates from DataManager.
|
||||
* Templates are loaded with seeded data, so this uses cache.
|
||||
* Use forceRefresh to trigger a full seeded data refresh.
|
||||
*/
|
||||
suspend fun getTaskTemplates(forceRefresh: Boolean = false): ApiResult<List<TaskTemplate>> {
|
||||
if (!forceRefresh) {
|
||||
val cached = DataManager.taskTemplates.value
|
||||
if (cached.isNotEmpty()) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
if (forceRefresh) {
|
||||
// Force refresh via seeded data endpoint (includes templates)
|
||||
refreshLookupsIfChanged()
|
||||
}
|
||||
|
||||
val result = taskTemplateApi.getTemplates()
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTaskTemplates(result.data)
|
||||
val cached = DataManager.taskTemplates.value
|
||||
if (cached.isNotEmpty()) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
|
||||
return result
|
||||
// If still empty, initialize lookups (which includes templates via seeded data)
|
||||
initializeLookups()
|
||||
return ApiResult.Success(DataManager.taskTemplates.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task templates grouped by category.
|
||||
* Task templates are PUBLIC (no auth required).
|
||||
* Templates are loaded with seeded data, so this uses cache.
|
||||
*/
|
||||
suspend fun getTaskTemplatesGrouped(forceRefresh: Boolean = false): ApiResult<TaskTemplatesGroupedResponse> {
|
||||
if (!forceRefresh) {
|
||||
val cached = DataManager.taskTemplatesGrouped.value
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
if (forceRefresh) {
|
||||
// Force refresh via seeded data endpoint (includes templates)
|
||||
refreshLookupsIfChanged()
|
||||
}
|
||||
|
||||
val result = taskTemplateApi.getTemplatesGrouped()
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTaskTemplatesGrouped(result.data)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Search task templates by query string.
|
||||
* First searches local cache, falls back to API if needed.
|
||||
*/
|
||||
suspend fun searchTaskTemplates(query: String): ApiResult<List<TaskTemplate>> {
|
||||
// Try local search first if we have templates cached
|
||||
val cached = DataManager.taskTemplates.value
|
||||
if (cached.isNotEmpty()) {
|
||||
val results = DataManager.searchTaskTemplates(query)
|
||||
return ApiResult.Success(results)
|
||||
}
|
||||
|
||||
// Fall back to API search
|
||||
return taskTemplateApi.searchTemplates(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category ID.
|
||||
*/
|
||||
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
|
||||
// Try to get from grouped cache first
|
||||
val grouped = DataManager.taskTemplatesGrouped.value
|
||||
if (grouped != null) {
|
||||
val categoryTemplates = grouped.categories
|
||||
.find { it.categoryId == categoryId }?.templates
|
||||
if (categoryTemplates != null) {
|
||||
return ApiResult.Success(categoryTemplates)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to API
|
||||
return taskTemplateApi.getTemplatesByCategory(categoryId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single task template by ID.
|
||||
*/
|
||||
suspend fun getTaskTemplate(id: Int): ApiResult<TaskTemplate> {
|
||||
// Try to find in cache first
|
||||
val cached = DataManager.taskTemplates.value.find { it.id == id }
|
||||
val cached = DataManager.taskTemplatesGrouped.value
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
|
||||
// Fall back to API
|
||||
return taskTemplateApi.getTemplate(id)
|
||||
// If still empty, initialize lookups (which includes templates via seeded data)
|
||||
initializeLookups()
|
||||
return DataManager.taskTemplatesGrouped.value?.let {
|
||||
ApiResult.Success(it)
|
||||
} ?: ApiResult.Error("Failed to load task templates")
|
||||
}
|
||||
|
||||
/**
|
||||
* Search task templates by query string.
|
||||
* Uses local cache only - templates are loaded with seeded data.
|
||||
*/
|
||||
suspend fun searchTaskTemplates(query: String): ApiResult<List<TaskTemplate>> {
|
||||
// Ensure templates are loaded
|
||||
if (DataManager.taskTemplates.value.isEmpty()) {
|
||||
initializeLookups()
|
||||
}
|
||||
|
||||
val results = DataManager.searchTaskTemplates(query)
|
||||
return ApiResult.Success(results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category ID.
|
||||
* Uses local cache only - templates are loaded with seeded data.
|
||||
*/
|
||||
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
|
||||
// Ensure templates are loaded
|
||||
if (DataManager.taskTemplatesGrouped.value == null) {
|
||||
initializeLookups()
|
||||
}
|
||||
|
||||
val grouped = DataManager.taskTemplatesGrouped.value
|
||||
val categoryTemplates = grouped?.categories
|
||||
?.find { it.categoryId == categoryId }?.templates
|
||||
?: emptyList()
|
||||
|
||||
return ApiResult.Success(categoryTemplates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single task template by ID.
|
||||
* Uses local cache only - templates are loaded with seeded data.
|
||||
*/
|
||||
suspend fun getTaskTemplate(id: Int): ApiResult<TaskTemplate> {
|
||||
// Ensure templates are loaded
|
||||
if (DataManager.taskTemplates.value.isEmpty()) {
|
||||
initializeLookups()
|
||||
}
|
||||
|
||||
val cached = DataManager.taskTemplates.value.find { it.id == id }
|
||||
return cached?.let {
|
||||
ApiResult.Success(it)
|
||||
} ?: ApiResult.Error("Task template not found")
|
||||
}
|
||||
|
||||
// ==================== Auth Operations ====================
|
||||
|
||||
@@ -4,8 +4,32 @@ import com.example.casera.models.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
|
||||
/**
|
||||
* Result type for conditional HTTP requests with ETag support.
|
||||
* Used to efficiently check if data has changed on the server.
|
||||
*/
|
||||
sealed class ConditionalResult<T> {
|
||||
/**
|
||||
* Server returned new data (HTTP 200).
|
||||
* Includes the new ETag for future conditional requests.
|
||||
*/
|
||||
data class Success<T>(val data: T, val etag: String?) : ConditionalResult<T>()
|
||||
|
||||
/**
|
||||
* Data has not changed since the provided ETag (HTTP 304).
|
||||
* Client should continue using cached data.
|
||||
*/
|
||||
class NotModified<T> : ConditionalResult<T>()
|
||||
|
||||
/**
|
||||
* Request failed with an error.
|
||||
*/
|
||||
data class Error<T>(val message: String, val statusCode: Int? = null) : ConditionalResult<T>()
|
||||
}
|
||||
|
||||
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
@@ -137,4 +161,47 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches unified seeded data (all lookups + task templates) with ETag support.
|
||||
*
|
||||
* @param currentETag The ETag from a previous response. If provided and data hasn't changed,
|
||||
* server returns 304 Not Modified.
|
||||
* @param token Optional auth token (endpoint is public).
|
||||
* @return ConditionalResult with data and new ETag, NotModified if unchanged, or Error.
|
||||
*/
|
||||
suspend fun getSeededData(
|
||||
currentETag: String? = null,
|
||||
token: String? = null
|
||||
): ConditionalResult<SeededDataResponse> {
|
||||
return try {
|
||||
val response: HttpResponse = client.get("$baseUrl/static_data/") {
|
||||
// Token is optional - endpoint is public
|
||||
token?.let { header("Authorization", "Token $it") }
|
||||
// Send If-None-Match header for conditional request
|
||||
currentETag?.let { header("If-None-Match", it) }
|
||||
}
|
||||
|
||||
when {
|
||||
response.status == HttpStatusCode.NotModified -> {
|
||||
// Data hasn't changed since provided ETag
|
||||
ConditionalResult.NotModified()
|
||||
}
|
||||
response.status.isSuccess() -> {
|
||||
// Data has changed or first request - get new data and ETag
|
||||
val data: SeededDataResponse = response.body()
|
||||
val newETag = response.headers["ETag"]
|
||||
ConditionalResult.Success(data, newETag)
|
||||
}
|
||||
else -> {
|
||||
ConditionalResult.Error(
|
||||
"Failed to fetch seeded data",
|
||||
response.status.value
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ConditionalResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.example.casera.models.Contractor
|
||||
|
||||
/**
|
||||
* Platform-specific composable that handles contractor import flow.
|
||||
* On Android, shows dialogs to confirm and execute import.
|
||||
* On other platforms, this is a no-op.
|
||||
*
|
||||
* @param pendingContractorImportUri Platform-specific URI object (e.g., android.net.Uri)
|
||||
* @param onClearContractorImport Called when import flow is complete
|
||||
* @param onImportSuccess Called when a contractor is successfully imported
|
||||
*/
|
||||
@Composable
|
||||
expect fun ContractorImportHandler(
|
||||
pendingContractorImportUri: Any?,
|
||||
onClearContractorImport: () -> Unit,
|
||||
onImportSuccess: (Contractor) -> Unit = {}
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.example.casera.models.Contractor
|
||||
|
||||
/**
|
||||
* Returns a function that can be called to share a contractor.
|
||||
* The returned function will open the native share sheet with a .casera file.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberShareContractor(): (Contractor) -> Unit
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.example.casera.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.casera.models.SharedContractor
|
||||
|
||||
/**
|
||||
* Dialog shown when a user attempts to import a contractor from a .casera file.
|
||||
* Shows contractor details and asks for confirmation.
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportConfirmDialog(
|
||||
sharedContractor: SharedContractor,
|
||||
isImporting: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!isImporting) onDismiss() },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Import Contractor",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Would you like to import this contractor?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Contractor details
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = sharedContractor.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
sharedContractor.company?.let { company ->
|
||||
Text(
|
||||
text = company,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (sharedContractor.specialtyNames.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = sharedContractor.specialtyNames.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
sharedContractor.exportedBy?.let { exportedBy ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Shared by: $exportedBy",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = !isImporting,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isImporting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Importing...")
|
||||
} else {
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isImporting
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown after a contractor import attempt succeeds.
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportSuccessDialog(
|
||||
contractorName: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Contractor Imported",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "$contractorName has been added to your contacts.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown after a contractor import attempt fails.
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportErrorDialog(
|
||||
errorMessage: String,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Import Failed",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
if (onRetry != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRetry()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("Try Again")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (onRetry != null) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -24,11 +24,12 @@ fun ManageUsersDialog(
|
||||
residenceId: Int,
|
||||
residenceName: String,
|
||||
isPrimaryOwner: Boolean,
|
||||
residenceOwnerId: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onUserRemoved: () -> Unit = {}
|
||||
) {
|
||||
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
|
||||
var ownerId by remember { mutableStateOf<Int?>(null) }
|
||||
val ownerId = residenceOwnerId
|
||||
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
@@ -46,8 +47,7 @@ fun ManageUsersDialog(
|
||||
if (token != null) {
|
||||
when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
|
||||
is ApiResult.Success -> {
|
||||
users = result.data.users
|
||||
ownerId = result.data.owner.id
|
||||
users = result.data
|
||||
isLoading = false
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.example.casera.ui.components.HandleErrors
|
||||
import com.example.casera.util.DateUtils
|
||||
import com.example.casera.viewmodel.ContractorViewModel
|
||||
import com.example.casera.network.ApiResult
|
||||
import com.example.casera.platform.rememberShareContractor
|
||||
import casera.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@@ -45,6 +46,8 @@ fun ContractorDetailScreen(
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
val shareContractor = rememberShareContractor()
|
||||
|
||||
LaunchedEffect(contractorId) {
|
||||
viewModel.loadContractorDetail(contractorId)
|
||||
}
|
||||
@@ -87,6 +90,9 @@ fun ContractorDetailScreen(
|
||||
actions = {
|
||||
when (val state = contractorState) {
|
||||
is ApiResult.Success -> {
|
||||
IconButton(onClick = { shareContractor(state.data) }) {
|
||||
Icon(Icons.Default.Share, stringResource(Res.string.common_share))
|
||||
}
|
||||
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
|
||||
Icon(
|
||||
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
|
||||
@@ -232,6 +232,7 @@ fun ResidenceDetailScreen(
|
||||
residenceId = residence.id,
|
||||
residenceName = residence.name,
|
||||
isPrimaryOwner = residence.ownerId == currentUser?.id,
|
||||
residenceOwnerId = residence.ownerId,
|
||||
onDismiss = {
|
||||
showManageUsersDialog = false
|
||||
},
|
||||
|
||||
@@ -113,6 +113,15 @@ fun ResidencesScreen(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateToProfile) {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
contentDescription = stringResource(Res.string.profile_title),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Only show Join button if not blocked (limit>0)
|
||||
if (!isBlocked.allowed) {
|
||||
@@ -128,11 +137,23 @@ fun ResidencesScreen(
|
||||
Icon(Icons.Default.GroupAdd, contentDescription = stringResource(Res.string.properties_join_title))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onNavigateToProfile) {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = stringResource(Res.string.profile_title))
|
||||
}
|
||||
IconButton(onClick = onLogout) {
|
||||
Icon(Icons.Default.ExitToApp, contentDescription = stringResource(Res.string.home_logout))
|
||||
// Add property button
|
||||
if (!isBlocked.allowed) {
|
||||
IconButton(onClick = {
|
||||
val (allowed, triggerKey) = canAddProperty()
|
||||
if (allowed) {
|
||||
onAddResidence()
|
||||
} else {
|
||||
upgradeTriggerKey = triggerKey
|
||||
showUpgradePrompt = true
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.AddCircle,
|
||||
contentDescription = stringResource(Res.string.properties_add_button),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
|
||||
Reference in New Issue
Block a user