Add contractor sharing feature and move settings to navigation bar

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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