Rebrand from MyCrib to Casera

- Rename Kotlin package from com.example.mycrib to com.example.casera
- Update Android app name, namespace, and application ID
- Update iOS bundle identifiers and project settings
- Rename iOS directories (MyCribTests -> CaseraTests, etc.)
- Update deep link schemes from mycrib:// to casera://
- Update app group identifiers
- Update subscription product IDs
- Update all UI strings and branding

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-28 21:10:38 -06:00
parent 8dbc816a33
commit c6eef720ed
215 changed files with 767 additions and 767 deletions

View File

@@ -0,0 +1,581 @@
package com.example.casera
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.example.casera.ui.screens.AddResidenceScreen
import com.example.casera.ui.screens.EditResidenceScreen
import com.example.casera.ui.screens.EditTaskScreen
import com.example.casera.ui.screens.ForgotPasswordScreen
import com.example.casera.ui.screens.HomeScreen
import com.example.casera.ui.screens.LoginScreen
import com.example.casera.ui.screens.RegisterScreen
import com.example.casera.ui.screens.ResetPasswordScreen
import com.example.casera.ui.screens.ResidenceDetailScreen
import com.example.casera.ui.screens.ResidencesScreen
import com.example.casera.ui.screens.TasksScreen
import com.example.casera.ui.screens.VerifyEmailScreen
import com.example.casera.ui.screens.VerifyResetCodeScreen
import com.example.casera.viewmodel.PasswordResetViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.example.casera.ui.screens.MainScreen
import com.example.casera.ui.screens.ProfileScreen
import com.example.casera.ui.theme.MyCribTheme
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.navigation.*
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
import com.example.casera.models.TaskCategory
import com.example.casera.models.TaskDetail
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskPriority
import com.example.casera.models.TaskStatus
import com.example.casera.network.ApiResult
import com.example.casera.network.AuthApi
import com.example.casera.storage.TokenStorage
import casera.composeapp.generated.resources.Res
import casera.composeapp.generated.resources.compose_multiplatform
@Composable
@Preview
fun App(
deepLinkResetToken: String? = null,
onClearDeepLinkToken: () -> Unit = {}
) {
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
var isVerified by remember { mutableStateOf(false) }
var isCheckingAuth by remember { mutableStateOf(true) }
val navController = rememberNavController()
// Check for stored token and verification status on app start
LaunchedEffect(Unit) {
val hasToken = TokenStorage.hasToken()
isLoggedIn = hasToken
if (hasToken) {
// Fetch current user to check verification status
val authApi = AuthApi()
val token = TokenStorage.getToken()
if (token != null) {
when (val result = authApi.getCurrentUser(token)) {
is ApiResult.Success -> {
isVerified = result.data.verified
LookupsRepository.initialize()
}
else -> {
// If fetching user fails, clear token and logout
TokenStorage.clearToken()
isLoggedIn = false
}
}
}
}
isCheckingAuth = false
}
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
MyCribTheme(themeColors = currentTheme) {
if (isCheckingAuth) {
// Show loading screen while checking auth
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
return@MyCribTheme
}
val startDestination = when {
deepLinkResetToken != null -> ForgotPasswordRoute
!isLoggedIn -> LoginRoute
!isVerified -> VerifyEmailRoute
else -> MainRoute
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable<LoginRoute> {
LoginScreen(
onLoginSuccess = { user ->
isLoggedIn = true
isVerified = user.verified
// Initialize lookups after successful login
LookupsRepository.initialize()
// Check if user is verified
if (user.verified) {
navController.navigate(MainRoute) {
popUpTo<LoginRoute> { inclusive = true }
}
} else {
navController.navigate(VerifyEmailRoute) {
popUpTo<LoginRoute> { inclusive = true }
}
}
},
onNavigateToRegister = {
navController.navigate(RegisterRoute)
},
onNavigateToForgotPassword = {
navController.navigate(ForgotPasswordRoute)
}
)
}
composable<RegisterRoute> {
RegisterScreen(
onRegisterSuccess = {
isLoggedIn = true
isVerified = false
// Initialize lookups after successful registration
LookupsRepository.initialize()
navController.navigate(VerifyEmailRoute) {
popUpTo<RegisterRoute> { inclusive = true }
}
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ForgotPasswordRoute> { backStackEntry ->
// Create shared ViewModel for all password reset screens
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<ForgotPasswordRoute>()
}
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) {
PasswordResetViewModel(deepLinkToken = deepLinkResetToken)
}
ForgotPasswordScreen(
onNavigateBack = {
// Clear deep link token when navigating back to login
onClearDeepLinkToken()
navController.popBackStack()
},
onNavigateToVerify = {
navController.navigate(VerifyResetCodeRoute)
},
onNavigateToReset = {
navController.navigate(ResetPasswordRoute)
},
viewModel = passwordResetViewModel
)
}
composable<VerifyResetCodeRoute> { backStackEntry ->
// Use shared ViewModel from ForgotPasswordRoute
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<ForgotPasswordRoute>()
}
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
VerifyResetCodeScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToReset = {
navController.navigate(ResetPasswordRoute)
},
viewModel = passwordResetViewModel
)
}
composable<ResetPasswordRoute> { backStackEntry ->
// Use shared ViewModel from ForgotPasswordRoute
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<ForgotPasswordRoute>()
}
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
ResetPasswordScreen(
onPasswordResetSuccess = {
// Clear deep link token and navigate back to login after successful password reset
onClearDeepLinkToken()
navController.navigate(LoginRoute) {
popUpTo<ForgotPasswordRoute> { inclusive = true }
}
},
onNavigateBack = {
navController.popBackStack()
},
viewModel = passwordResetViewModel
)
}
composable<VerifyEmailRoute> {
VerifyEmailScreen(
onVerifySuccess = {
isVerified = true
navController.navigate(MainRoute) {
popUpTo<VerifyEmailRoute> { inclusive = true }
}
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<VerifyEmailRoute> { inclusive = true }
}
}
)
}
composable<MainRoute> {
MainScreen(
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<MainRoute> { inclusive = true }
}
},
onResidenceClick = { residenceId ->
navController.navigate(ResidenceDetailRoute(residenceId))
},
onAddResidence = {
navController.navigate(AddResidenceRoute)
},
onAddTask = {
// Tasks are added from within a residence
// Navigate to first residence or show message if no residences exist
// For now, this will be handled by the UI showing "add a property first"
},
onNavigateToEditResidence = { residence ->
navController.navigate(
EditResidenceRoute(
residenceId = residence.id,
name = residence.name,
propertyType = residence.propertyTypeId,
streetAddress = residence.streetAddress,
apartmentUnit = residence.apartmentUnit,
city = residence.city,
stateProvince = residence.stateProvince,
postalCode = residence.postalCode,
country = residence.country,
bedrooms = residence.bedrooms,
bathrooms = residence.bathrooms?.toFloat(),
squareFootage = residence.squareFootage,
lotSize = residence.lotSize?.toFloat(),
yearBuilt = residence.yearBuilt,
description = residence.description,
isPrimary = residence.isPrimary,
ownerUserName = residence.ownerUsername,
createdAt = residence.createdAt,
updatedAt = residence.updatedAt,
owner = residence.ownerId
)
)
},
onNavigateToEditTask = { task ->
navController.navigate(
EditTaskRoute(
taskId = task.id,
residenceId = task.residenceId,
title = task.title,
description = task.description,
categoryId = task.category?.id ?: 0,
categoryName = task.category?.name ?: "",
frequencyId = task.frequency?.id ?: 0,
frequencyName = task.frequency?.name ?: "",
priorityId = task.priority?.id ?: 0,
priorityName = task.priority?.name ?: "",
statusId = task.status?.id,
statusName = task.status?.name,
dueDate = task.dueDate,
estimatedCost = task.estimatedCost?.toString(),
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
)
}
)
}
composable<HomeRoute> {
HomeScreen(
onNavigateToResidences = {
navController.navigate(MainRoute)
},
onNavigateToTasks = {
navController.navigate(TasksRoute)
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true }
}
}
)
}
composable<ResidencesRoute> { backStackEntry ->
// Get refresh flag from saved state (set when returning from add/edit)
val shouldRefresh = backStackEntry.savedStateHandle.get<Boolean>("refresh") ?: false
ResidencesScreen(
onResidenceClick = { residenceId ->
navController.navigate(ResidenceDetailRoute(residenceId))
},
onAddResidence = {
navController.navigate(AddResidenceRoute)
},
onNavigateToProfile = {
navController.navigate(ProfileRoute)
},
shouldRefresh = shouldRefresh,
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true }
}
}
)
}
composable<AddResidenceRoute> {
AddResidenceScreen(
onNavigateBack = {
navController.popBackStack()
},
onResidenceCreated = {
// Set refresh flag before navigating back
navController.previousBackStackEntry?.savedStateHandle?.set("refresh", true)
navController.popBackStack()
}
)
}
composable<EditResidenceRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditResidenceRoute>()
EditResidenceScreen(
residence = Residence(
id = route.residenceId,
ownerId = route.owner ?: 0,
name = route.name,
propertyTypeId = route.propertyType,
streetAddress = route.streetAddress ?: "",
apartmentUnit = route.apartmentUnit ?: "",
city = route.city ?: "",
stateProvince = route.stateProvince ?: "",
postalCode = route.postalCode ?: "",
country = route.country ?: "",
bedrooms = route.bedrooms,
bathrooms = route.bathrooms?.toDouble(),
squareFootage = route.squareFootage,
lotSize = route.lotSize?.toDouble(),
yearBuilt = route.yearBuilt,
description = route.description ?: "",
purchaseDate = null,
purchasePrice = null,
isPrimary = route.isPrimary,
createdAt = route.createdAt,
updatedAt = route.updatedAt
),
onNavigateBack = {
navController.popBackStack()
},
onResidenceUpdated = {
// Set refresh flag before navigating back
navController.previousBackStackEntry?.savedStateHandle?.set("refresh", true)
navController.popBackStack()
}
)
}
composable<TasksRoute> {
TasksScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ResidenceDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ResidenceDetailRoute>()
ResidenceDetailScreen(
residenceId = route.residenceId,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToEditResidence = { residence ->
navController.navigate(
EditResidenceRoute(
residenceId = residence.id,
name = residence.name,
propertyType = residence.propertyTypeId,
streetAddress = residence.streetAddress,
apartmentUnit = residence.apartmentUnit,
city = residence.city,
stateProvince = residence.stateProvince,
postalCode = residence.postalCode,
country = residence.country,
bedrooms = residence.bedrooms,
bathrooms = residence.bathrooms?.toFloat(),
squareFootage = residence.squareFootage,
lotSize = residence.lotSize?.toFloat(),
yearBuilt = residence.yearBuilt,
description = residence.description,
isPrimary = residence.isPrimary,
ownerUserName = residence.ownerUsername,
createdAt = residence.createdAt,
updatedAt = residence.updatedAt,
owner = residence.ownerId
)
)
},
onNavigateToEditTask = { task ->
navController.navigate(
EditTaskRoute(
taskId = task.id,
residenceId = task.residenceId,
title = task.title,
description = task.description,
categoryId = task.category?.id ?: 0,
categoryName = task.category?.name ?: "",
frequencyId = task.frequency?.id ?: 0,
frequencyName = task.frequency?.name ?: "",
priorityId = task.priority?.id ?: 0,
priorityName = task.priority?.name ?: "",
statusId = task.status?.id,
statusName = task.status?.name,
dueDate = task.dueDate,
estimatedCost = task.estimatedCost?.toString(),
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
)
}
)
}
composable<EditTaskRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditTaskRoute>()
EditTaskScreen(
task = TaskDetail(
id = route.taskId,
residenceId = route.residenceId,
createdById = 0,
title = route.title,
description = route.description ?: "",
category = TaskCategory(id = route.categoryId, name = route.categoryName),
frequency = TaskFrequency(
id = route.frequencyId,
name = route.frequencyName,
days = null
),
priority = TaskPriority(id = route.priorityId, name = route.priorityName),
status = route.statusId?.let {
TaskStatus(id = it, name = route.statusName ?: "")
},
dueDate = route.dueDate,
estimatedCost = route.estimatedCost?.toDoubleOrNull(),
createdAt = route.createdAt,
updatedAt = route.updatedAt,
completions = emptyList()
),
onNavigateBack = { navController.popBackStack() },
onTaskUpdated = { navController.popBackStack() }
)
}
composable<ProfileRoute> {
ProfileScreen(
onNavigateBack = {
navController.popBackStack()
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<ProfileRoute> { inclusive = true }
}
}
)
}
}
}
}
/*
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
}
}
}
*/
}

View File

@@ -0,0 +1,9 @@
package com.example.casera
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View File

@@ -0,0 +1,25 @@
//package com.casera.android
//
//import android.os.Bundle
//import androidx.activity.ComponentActivity
//import androidx.activity.compose.setContent
//import androidx.compose.foundation.layout.*
//import androidx.compose.material3.*
//import androidx.compose.runtime.*
//import androidx.compose.ui.Modifier
//import androidx.navigation.compose.NavHost
//import androidx.navigation.compose.composable
//import androidx.navigation.compose.rememberNavController
//import com.example.casera.ui.screens.*
//import com.example.casera.ui.theme.MyCribTheme
//
//class MainActivity : ComponentActivity() {
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// setContent {
// MyCribTheme {
// MyCribApp()
// }
// }
// }
//}

View File

@@ -0,0 +1,7 @@
package com.example.casera
interface Platform {
val name: String
}
expect fun getPlatform(): Platform

View File

@@ -0,0 +1,301 @@
package com.example.casera.cache
import com.example.casera.models.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
//import kotlinx.datetime.Clock
//import kotlinx.datetime.Instant
/**
* Centralized data cache for the application.
* This singleton holds all frequently accessed data in memory to avoid redundant API calls.
*/
object DataCache {
// User & Authentication
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
// Residences
private val _residences = MutableStateFlow<List<Residence>>(emptyList())
val residences: StateFlow<List<Residence>> = _residences.asStateFlow()
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
// Tasks
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
// Documents
private val _documents = MutableStateFlow<List<Document>>(emptyList())
val documents: StateFlow<List<Document>> = _documents.asStateFlow()
private val _documentsByResidence = MutableStateFlow<Map<Int, List<Document>>>(emptyMap())
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
// Contractors
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
// Lookups/Reference Data - List-based (for dropdowns/pickers)
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses.asStateFlow()
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
// Lookups/Reference Data - Map-based (for O(1) ID resolution)
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()
private val _taskFrequenciesMap = MutableStateFlow<Map<Int, TaskFrequency>>(emptyMap())
val taskFrequenciesMap: StateFlow<Map<Int, TaskFrequency>> = _taskFrequenciesMap.asStateFlow()
private val _taskPrioritiesMap = MutableStateFlow<Map<Int, TaskPriority>>(emptyMap())
val taskPrioritiesMap: StateFlow<Map<Int, TaskPriority>> = _taskPrioritiesMap.asStateFlow()
private val _taskStatusesMap = MutableStateFlow<Map<Int, TaskStatus>>(emptyMap())
val taskStatusesMap: StateFlow<Map<Int, TaskStatus>> = _taskStatusesMap.asStateFlow()
private val _taskCategoriesMap = MutableStateFlow<Map<Int, TaskCategory>>(emptyMap())
val taskCategoriesMap: StateFlow<Map<Int, TaskCategory>> = _taskCategoriesMap.asStateFlow()
private val _contractorSpecialtiesMap = MutableStateFlow<Map<Int, ContractorSpecialty>>(emptyMap())
val contractorSpecialtiesMap: StateFlow<Map<Int, ContractorSpecialty>> = _contractorSpecialtiesMap.asStateFlow()
private val _lookupsInitialized = MutableStateFlow(false)
val lookupsInitialized: StateFlow<Boolean> = _lookupsInitialized.asStateFlow()
// O(1) lookup helper methods - resolve ID to full object
fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] }
fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] }
fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] }
fun getTaskStatus(id: Int?): TaskStatus? = id?.let { _taskStatusesMap.value[it] }
fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] }
fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] }
// Cache metadata
private val _lastRefreshTime = MutableStateFlow<Long>(0L)
val lastRefreshTime: StateFlow<Long> = _lastRefreshTime.asStateFlow()
private val _isCacheInitialized = MutableStateFlow(false)
val isCacheInitialized: StateFlow<Boolean> = _isCacheInitialized.asStateFlow()
// Update methods
fun updateCurrentUser(user: User?) {
_currentUser.value = user
}
fun updateResidences(residences: List<Residence>) {
_residences.value = residences
updateLastRefreshTime()
}
fun updateMyResidences(myResidences: MyResidencesResponse) {
_myResidences.value = myResidences
updateLastRefreshTime()
}
fun updateResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
}
fun updateAllTasks(tasks: TaskColumnsResponse) {
_allTasks.value = tasks
updateLastRefreshTime()
}
fun updateTasksByResidence(residenceId: Int, tasks: TaskColumnsResponse) {
_tasksByResidence.value = _tasksByResidence.value + (residenceId to tasks)
}
fun updateDocuments(documents: List<Document>) {
_documents.value = documents
updateLastRefreshTime()
}
fun updateDocumentsByResidence(residenceId: Int, documents: List<Document>) {
_documentsByResidence.value = _documentsByResidence.value + (residenceId to documents)
}
fun updateContractors(contractors: List<Contractor>) {
_contractors.value = contractors
updateLastRefreshTime()
}
// Lookup update methods removed - lookups are handled by LookupsViewModel
fun setCacheInitialized(initialized: Boolean) {
_isCacheInitialized.value = initialized
}
@OptIn(ExperimentalTime::class)
private fun updateLastRefreshTime() {
_lastRefreshTime.value = Clock.System.now().toEpochMilliseconds()
}
// Helper methods to add/update/remove individual items
fun addResidence(residence: Residence) {
_residences.value = _residences.value + residence
}
fun updateResidence(residence: Residence) {
_residences.value = _residences.value.map {
if (it.id == residence.id) residence else it
}
}
fun removeResidence(residenceId: Int) {
_residences.value = _residences.value.filter { it.id != residenceId }
// Also clear related caches
_tasksByResidence.value = _tasksByResidence.value - residenceId
_documentsByResidence.value = _documentsByResidence.value - residenceId
_residenceSummaries.value = _residenceSummaries.value - residenceId
}
fun addDocument(document: Document) {
_documents.value = _documents.value + document
}
fun updateDocument(document: Document) {
_documents.value = _documents.value.map {
if (it.id == document.id) document else it
}
}
fun removeDocument(documentId: Int) {
_documents.value = _documents.value.filter { it.id != documentId }
}
fun addContractor(contractor: Contractor) {
_contractors.value = _contractors.value + contractor
}
fun updateContractor(contractor: Contractor) {
_contractors.value = _contractors.value.map {
if (it.id == contractor.id) contractor else it
}
}
fun removeContractor(contractorId: Int) {
_contractors.value = _contractors.value.filter { it.id != contractorId }
}
// Lookup update methods - update both list and map versions
fun updateResidenceTypes(types: List<ResidenceType>) {
_residenceTypes.value = types
_residenceTypesMap.value = types.associateBy { it.id }
}
fun updateTaskFrequencies(frequencies: List<TaskFrequency>) {
_taskFrequencies.value = frequencies
_taskFrequenciesMap.value = frequencies.associateBy { it.id }
}
fun updateTaskPriorities(priorities: List<TaskPriority>) {
_taskPriorities.value = priorities
_taskPrioritiesMap.value = priorities.associateBy { it.id }
}
fun updateTaskStatuses(statuses: List<TaskStatus>) {
_taskStatuses.value = statuses
_taskStatusesMap.value = statuses.associateBy { it.id }
}
fun updateTaskCategories(categories: List<TaskCategory>) {
_taskCategories.value = categories
_taskCategoriesMap.value = categories.associateBy { it.id }
}
fun updateContractorSpecialties(specialties: List<ContractorSpecialty>) {
_contractorSpecialties.value = specialties
_contractorSpecialtiesMap.value = specialties.associateBy { it.id }
}
fun updateAllLookups(staticData: StaticDataResponse) {
_residenceTypes.value = staticData.residenceTypes
_residenceTypesMap.value = staticData.residenceTypes.associateBy { it.id }
_taskFrequencies.value = staticData.taskFrequencies
_taskFrequenciesMap.value = staticData.taskFrequencies.associateBy { it.id }
_taskPriorities.value = staticData.taskPriorities
_taskPrioritiesMap.value = staticData.taskPriorities.associateBy { it.id }
_taskStatuses.value = staticData.taskStatuses
_taskStatusesMap.value = staticData.taskStatuses.associateBy { it.id }
_taskCategories.value = staticData.taskCategories
_taskCategoriesMap.value = staticData.taskCategories.associateBy { it.id }
_contractorSpecialties.value = staticData.contractorSpecialties
_contractorSpecialtiesMap.value = staticData.contractorSpecialties.associateBy { it.id }
}
fun markLookupsInitialized() {
_lookupsInitialized.value = true
}
// Clear methods
fun clearAll() {
_currentUser.value = null
_residences.value = emptyList()
_myResidences.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_contractors.value = emptyList()
clearLookups()
_lastRefreshTime.value = 0L
_isCacheInitialized.value = false
}
fun clearLookups() {
_residenceTypes.value = emptyList()
_residenceTypesMap.value = emptyMap()
_taskFrequencies.value = emptyList()
_taskFrequenciesMap.value = emptyMap()
_taskPriorities.value = emptyList()
_taskPrioritiesMap.value = emptyMap()
_taskStatuses.value = emptyList()
_taskStatusesMap.value = emptyMap()
_taskCategories.value = emptyList()
_taskCategoriesMap.value = emptyMap()
_contractorSpecialties.value = emptyList()
_contractorSpecialtiesMap.value = emptyMap()
_lookupsInitialized.value = false
}
fun clearUserData() {
_currentUser.value = null
_residences.value = emptyList()
_myResidences.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
_documents.value = emptyList()
_documentsByResidence.value = emptyMap()
_contractors.value = emptyList()
_isCacheInitialized.value = false
}
}

View File

@@ -0,0 +1,200 @@
package com.example.casera.cache
import com.example.casera.network.*
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.*
/**
* Manager responsible for prefetching and caching data when the app launches.
* This ensures all screens have immediate access to data without making API calls.
*/
class DataPrefetchManager {
private val residenceApi = ResidenceApi()
private val taskApi = TaskApi()
private val documentApi = DocumentApi()
private val contractorApi = ContractorApi()
private val lookupsApi = LookupsApi()
/**
* Prefetch all essential data on app launch.
* This runs asynchronously and populates the DataCache.
*/
suspend fun prefetchAllData(): Result<Unit> = withContext(Dispatchers.Default) {
try {
val token = TokenStorage.getToken()
if (token == null) {
return@withContext Result.failure(Exception("Not authenticated"))
}
println("DataPrefetchManager: Starting data prefetch...")
// Launch all prefetch operations in parallel
val jobs = listOf(
async { prefetchResidences(token) },
async { prefetchMyResidences(token) },
async { prefetchTasks(token) },
async { prefetchDocuments(token) },
async { prefetchContractors(token) },
async { prefetchLookups(token) }
)
// Wait for all jobs to complete
jobs.awaitAll()
// Mark cache as initialized
DataCache.setCacheInitialized(true)
println("DataPrefetchManager: Data prefetch completed successfully")
Result.success(Unit)
} catch (e: Exception) {
println("DataPrefetchManager: Error during prefetch: ${e.message}")
e.printStackTrace()
Result.failure(e)
}
}
/**
* Refresh specific data types.
* Useful for pull-to-refresh functionality.
*/
suspend fun refreshResidences(): Result<Unit> = withContext(Dispatchers.Default) {
try {
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
prefetchResidences(token)
prefetchMyResidences(token)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun refreshTasks(): Result<Unit> = withContext(Dispatchers.Default) {
try {
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
prefetchTasks(token)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun refreshDocuments(): Result<Unit> = withContext(Dispatchers.Default) {
try {
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
prefetchDocuments(token)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun refreshContractors(): Result<Unit> = withContext(Dispatchers.Default) {
try {
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
prefetchContractors(token)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
// Private prefetch methods
private suspend fun prefetchResidences(token: String) {
try {
println("DataPrefetchManager: Fetching residences...")
val result = residenceApi.getResidences(token)
if (result is ApiResult.Success) {
DataCache.updateResidences(result.data)
println("DataPrefetchManager: Cached ${result.data.size} residences")
}
} catch (e: Exception) {
println("DataPrefetchManager: Error fetching residences: ${e.message}")
}
}
private suspend fun prefetchMyResidences(token: String) {
try {
println("DataPrefetchManager: Fetching my residences...")
val result = residenceApi.getMyResidences(token)
if (result is ApiResult.Success) {
DataCache.updateMyResidences(result.data)
println("DataPrefetchManager: Cached my residences")
}
} catch (e: Exception) {
println("DataPrefetchManager: Error fetching my residences: ${e.message}")
}
}
private suspend fun prefetchTasks(token: String) {
try {
println("DataPrefetchManager: Fetching tasks...")
val result = taskApi.getTasks(token)
if (result is ApiResult.Success) {
DataCache.updateAllTasks(result.data)
println("DataPrefetchManager: Cached tasks")
}
} catch (e: Exception) {
println("DataPrefetchManager: Error fetching tasks: ${e.message}")
}
}
private suspend fun prefetchDocuments(token: String) {
try {
println("DataPrefetchManager: Fetching documents...")
val result = documentApi.getDocuments(
token = token,
residenceId = null,
documentType = null,
category = null,
contractorId = null,
isActive = null,
expiringSoon = null,
tags = null,
search = null
)
if (result is ApiResult.Success) {
DataCache.updateDocuments(result.data)
println("DataPrefetchManager: Cached ${result.data.size} documents")
}
} catch (e: Exception) {
println("DataPrefetchManager: Error fetching documents: ${e.message}")
}
}
private suspend fun prefetchContractors(token: String) {
try {
println("DataPrefetchManager: Fetching contractors...")
val result = contractorApi.getContractors(
token = token,
specialty = null,
isFavorite = null,
isActive = null,
search = null
)
if (result is ApiResult.Success) {
// API returns List<ContractorSummary>, not List<Contractor>
// Skip caching for now - full Contractor objects will be cached when fetched individually
println("DataPrefetchManager: Fetched ${result.data.size} contractor summaries")
}
} catch (e: Exception) {
println("DataPrefetchManager: Error fetching contractors: ${e.message}")
}
}
private suspend fun prefetchLookups(token: String) {
// Lookups are handled separately by LookupsViewModel with their own caching
println("DataPrefetchManager: Skipping lookups prefetch (handled by LookupsViewModel)")
}
companion object {
private var instance: DataPrefetchManager? = null
fun getInstance(): DataPrefetchManager {
if (instance == null) {
instance = DataPrefetchManager()
}
return instance!!
}
}
}

View File

@@ -0,0 +1,159 @@
# Data Caching Implementation
## Overview
This app now uses a centralized caching system to avoid redundant API calls when navigating between screens.
## How It Works
1. **App Launch**: When the app launches and the user is authenticated, `DataPrefetchManager` automatically loads all essential data in parallel:
- Residences (all + my residences)
- Tasks (all tasks)
- Documents
- Contractors
- Lookup data (categories, priorities, frequencies, statuses)
2. **Data Access**: ViewModels check the `DataCache` first before making API calls:
- If cache has data and `forceRefresh=false`: Use cached data immediately
- If cache is empty or `forceRefresh=true`: Fetch from API and update cache
3. **Cache Updates**: When create/update/delete operations succeed, the cache is automatically updated
## Usage in ViewModels
### Load Data (with caching)
```kotlin
// In ViewModel
fun loadResidences(forceRefresh: Boolean = false) {
viewModelScope.launch {
// Check cache first
val cachedData = DataCache.residences.value
if (!forceRefresh && cachedData.isNotEmpty()) {
_residencesState.value = ApiResult.Success(cachedData)
return@launch
}
// Fetch from API if needed
_residencesState.value = ApiResult.Loading
val result = residenceApi.getResidences(token)
_residencesState.value = result
// Update cache on success
if (result is ApiResult.Success) {
DataCache.updateResidences(result.data)
}
}
}
```
### Update Cache After Mutations
```kotlin
fun createResidence(request: ResidenceCreateRequest) {
viewModelScope.launch {
val result = residenceApi.createResidence(token, request)
_createState.value = result
// Update cache on success
if (result is ApiResult.Success) {
DataCache.addResidence(result.data)
}
}
}
```
## iOS Integration
In your iOS app's main initialization (e.g., `iOSApp.swift`):
```swift
import ComposeApp
@main
struct MyCribApp: App {
init() {
// After successful login, prefetch data
Task {
await prefetchData()
}
}
func prefetchData() async {
let prefetchManager = DataPrefetchManager.Companion().getInstance()
_ = try? await prefetchManager.prefetchAllData()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
## Android Integration
In your Android `MainActivity`:
```kotlin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Check if authenticated
val token = TokenStorage.getToken()
if (token != null) {
// Prefetch data in background
lifecycleScope.launch {
DataPrefetchManager.getInstance().prefetchAllData()
}
}
setContent {
// Your compose content
}
}
}
```
## Pull to Refresh
To refresh data manually (e.g., pull-to-refresh):
```kotlin
// In ViewModel
fun refresh() {
viewModelScope.launch {
prefetchManager.refreshResidences()
loadResidences(forceRefresh = true)
}
}
```
## Benefits
1. **Instant Screen Load**: Screens show data immediately from cache
2. **Reduced API Calls**: No redundant calls when navigating between screens
3. **Better UX**: No loading spinners on every screen transition
4. **Offline Support**: Data remains available even with poor connectivity
5. **Consistent State**: All screens see the same data from cache
## Cache Lifecycle
- **Initialization**: App launch (after authentication)
- **Updates**: After successful create/update/delete operations
- **Clear**: On logout or authentication error
- **Refresh**: Manual pull-to-refresh or `forceRefresh=true`
## ViewModels Updated
The following ViewModels now use caching:
-`ResidenceViewModel`
-`TaskViewModel`
-`DocumentViewModel` (TODO)
-`ContractorViewModel` (TODO)
-`LookupsViewModel` (TODO)
## Note
For DocumentViewModel and ContractorViewModel, follow the same pattern as shown in ResidenceViewModel and TaskViewModel.

View File

@@ -0,0 +1,37 @@
package com.example.casera.cache
import androidx.compose.runtime.mutableStateOf
import com.example.casera.models.FeatureBenefit
import com.example.casera.models.Promotion
import com.example.casera.models.SubscriptionStatus
import com.example.casera.models.UpgradeTriggerData
object SubscriptionCache {
val currentSubscription = mutableStateOf<SubscriptionStatus?>(null)
val upgradeTriggers = mutableStateOf<Map<String, UpgradeTriggerData>>(emptyMap())
val featureBenefits = mutableStateOf<List<FeatureBenefit>>(emptyList())
val promotions = mutableStateOf<List<Promotion>>(emptyList())
fun updateSubscriptionStatus(subscription: SubscriptionStatus) {
currentSubscription.value = subscription
}
fun updateUpgradeTriggers(triggers: Map<String, UpgradeTriggerData>) {
upgradeTriggers.value = triggers
}
fun updateFeatureBenefits(benefits: List<FeatureBenefit>) {
featureBenefits.value = benefits
}
fun updatePromotions(promos: List<Promotion>) {
promotions.value = promos
}
fun clear() {
currentSubscription.value = null
upgradeTriggers.value = emptyMap()
featureBenefits.value = emptyList()
promotions.value = emptyList()
}
}

View File

@@ -0,0 +1,101 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Contractor(
val id: Int,
val name: String,
val company: String? = null,
val phone: String? = null,
val email: String? = null,
@SerialName("secondary_phone") val secondaryPhone: String? = null,
val specialty: String? = null,
@SerialName("license_number") val licenseNumber: String? = null,
val website: String? = null,
val address: String? = null,
val city: String? = null,
val state: String? = null,
@SerialName("zip_code") val zipCode: String? = null,
@SerialName("added_by") val addedBy: Int,
@SerialName("average_rating") val averageRating: Double? = null,
@SerialName("is_favorite") val isFavorite: Boolean = false,
@SerialName("is_active") val isActive: Boolean = true,
val notes: String? = null,
@SerialName("task_count") val taskCount: Int = 0,
@SerialName("last_used") val lastUsed: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
)
@Serializable
data class ContractorCreateRequest(
val name: String,
val company: String? = null,
val phone: String? = null,
val email: String? = null,
@SerialName("secondary_phone") val secondaryPhone: String? = null,
val specialty: String? = null,
@SerialName("license_number") val licenseNumber: String? = null,
val website: String? = null,
val address: String? = null,
val city: String? = null,
val state: String? = null,
@SerialName("zip_code") val zipCode: String? = null,
@SerialName("is_favorite") val isFavorite: Boolean = false,
@SerialName("is_active") val isActive: Boolean = true,
val notes: String? = null
)
@Serializable
data class ContractorUpdateRequest(
val name: String? = null,
val company: String? = null,
val phone: String? = null,
val email: String? = null,
@SerialName("secondary_phone") val secondaryPhone: String? = null,
val specialty: String? = null,
@SerialName("license_number") val licenseNumber: String? = null,
val website: String? = null,
val address: String? = null,
val city: String? = null,
val state: String? = null,
@SerialName("zip_code") val zipCode: String? = null,
@SerialName("is_favorite") val isFavorite: Boolean? = null,
@SerialName("is_active") val isActive: Boolean? = null,
val notes: String? = null
)
@Serializable
data class ContractorSummary(
val id: Int,
val name: String,
val company: String? = null,
val phone: String? = null,
val specialty: String? = null,
@SerialName("average_rating") val averageRating: Double? = null,
@SerialName("is_favorite") val isFavorite: Boolean = false,
@SerialName("task_count") val taskCount: Int = 0
)
/**
* Minimal contractor model for list views.
* Uses specialty_id instead of nested specialty object.
* Resolve via DataCache.getContractorSpecialty(contractor.specialtyId)
*/
@Serializable
data class ContractorMinimal(
val id: Int,
val name: String,
val company: String? = null,
val phone: String? = null,
@SerialName("specialty_id") val specialtyId: Int? = null,
@SerialName("average_rating") val averageRating: Double? = null,
@SerialName("is_favorite") val isFavorite: Boolean = false,
@SerialName("task_count") val taskCount: Int = 0,
@SerialName("last_used") val lastUsed: String? = null
)
// Removed: ContractorListResponse - no longer using paginated responses
// API now returns List<ContractorMinimal> directly from list endpoint

View File

@@ -0,0 +1,207 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* User reference for task-related responses - matching Go API TaskUserResponse
*/
@Serializable
data class TaskUserResponse(
val id: Int,
val username: String,
val email: String,
@SerialName("first_name") val firstName: String = "",
@SerialName("last_name") val lastName: String = ""
) {
val displayName: String
get() = when {
firstName.isNotBlank() && lastName.isNotBlank() -> "$firstName $lastName"
firstName.isNotBlank() -> firstName
lastName.isNotBlank() -> lastName
else -> username
}
}
/**
* Task response matching Go API TaskResponse
*/
@Serializable
data class TaskResponse(
val id: Int,
@SerialName("residence_id") val residenceId: Int,
@SerialName("created_by_id") val createdById: Int,
@SerialName("created_by") val createdBy: TaskUserResponse? = null,
@SerialName("assigned_to_id") val assignedToId: Int? = null,
@SerialName("assigned_to") val assignedTo: TaskUserResponse? = null,
val title: String,
val description: String = "",
@SerialName("category_id") val categoryId: Int? = null,
val category: TaskCategory? = null,
@SerialName("priority_id") val priorityId: Int? = null,
val priority: TaskPriority? = null,
@SerialName("status_id") val statusId: Int? = null,
val status: TaskStatus? = null,
@SerialName("frequency_id") val frequencyId: Int? = null,
val frequency: TaskFrequency? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("estimated_cost") val estimatedCost: Double? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
@SerialName("contractor_id") val contractorId: Int? = null,
@SerialName("is_cancelled") val isCancelled: Boolean = false,
@SerialName("is_archived") val isArchived: Boolean = false,
@SerialName("parent_task_id") val parentTaskId: Int? = null,
@SerialName("completion_count") val completionCount: Int = 0,
val completions: List<TaskCompletionResponse> = emptyList(),
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
) {
// Helper for backwards compatibility with old code
val archived: Boolean get() = isArchived
// Aliases for backwards compatibility with UI code expecting old field names
val residence: Int get() = residenceId
// Helper to get created by username
val createdByUsername: String get() = createdBy?.displayName ?: ""
// Computed properties for UI compatibility (these were in Django API but not Go API)
val categoryName: String? get() = category?.name
val categoryDescription: String? get() = category?.description
val frequencyName: String? get() = frequency?.name
val frequencyDisplayName: String? get() = frequency?.displayName
val frequencyDaySpan: Int? get() = frequency?.days
val priorityName: String? get() = priority?.name
val priorityDisplayName: String? get() = priority?.displayName
val statusName: String? get() = status?.name
// Fields that don't exist in Go API - return null/default
val nextScheduledDate: String? get() = null // Would need calculation based on frequency
val showCompletedButton: Boolean get() = status?.name?.lowercase() != "completed"
val daySpan: Int? get() = frequency?.days
val notifyDays: Int? get() = null // Not in Go API
}
/**
* Task completion response matching Go API TaskCompletionResponse
*/
@Serializable
data class TaskCompletionResponse(
val id: Int,
@SerialName("task_id") val taskId: Int,
@SerialName("completed_by") val completedBy: TaskUserResponse? = null,
@SerialName("completed_at") val completedAt: String,
val notes: String = "",
@SerialName("actual_cost") val actualCost: Double? = null,
val rating: Int? = null,
val images: List<TaskCompletionImage> = emptyList(),
@SerialName("created_at") val createdAt: String
) {
// Helper for backwards compatibility
val completionDate: String get() = completedAt
val completedByName: String? get() = completedBy?.displayName
// Backwards compatibility for UI that expects these fields
val task: Int get() = taskId
val contractor: Int? get() = null // Not in Go API - would need to be fetched separately
val contractorDetails: ContractorMinimal? get() = null // Not in Go API
}
/**
* Task create request matching Go API CreateTaskRequest
*/
@Serializable
data class TaskCreateRequest(
@SerialName("residence_id") val residenceId: Int,
val title: String,
val description: String? = null,
@SerialName("category_id") val categoryId: Int? = null,
@SerialName("priority_id") val priorityId: Int? = null,
@SerialName("status_id") val statusId: Int? = null,
@SerialName("frequency_id") val frequencyId: Int? = null,
@SerialName("assigned_to_id") val assignedToId: Int? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("estimated_cost") val estimatedCost: Double? = null,
@SerialName("contractor_id") val contractorId: Int? = null
)
/**
* Task update request matching Go API UpdateTaskRequest
* All fields are optional - only provided fields will be updated.
*/
@Serializable
data class TaskUpdateRequest(
val title: String? = null,
val description: String? = null,
@SerialName("category_id") val categoryId: Int? = null,
@SerialName("priority_id") val priorityId: Int? = null,
@SerialName("status_id") val statusId: Int? = null,
@SerialName("frequency_id") val frequencyId: Int? = null,
@SerialName("assigned_to_id") val assignedToId: Int? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("estimated_cost") val estimatedCost: Double? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
@SerialName("contractor_id") val contractorId: Int? = null
)
/**
* Task action response (for cancel, archive, mark in progress, etc.)
*/
@Serializable
data class TaskActionResponse(
val message: String,
val task: TaskResponse
)
/**
* Kanban column response matching Go API KanbanColumnResponse
*/
@Serializable
data class TaskColumn(
val name: String,
@SerialName("display_name") val displayName: String,
@SerialName("button_types") val buttonTypes: List<String>,
val icons: Map<String, String>,
val color: String,
val tasks: List<TaskResponse>,
val count: Int
)
/**
* Kanban board response matching Go API KanbanBoardResponse
*/
@Serializable
data class TaskColumnsResponse(
val columns: List<TaskColumn>,
@SerialName("days_threshold") val daysThreshold: Int,
@SerialName("residence_id") val residenceId: String
)
/**
* Task patch request for partial updates (status changes, archive/unarchive)
*/
@Serializable
data class TaskPatchRequest(
@SerialName("status_id") val status: Int? = null,
@SerialName("is_archived") val archived: Boolean? = null,
@SerialName("is_cancelled") val cancelled: Boolean? = null
)
/**
* Task completion image model
*/
@Serializable
data class TaskCompletionImage(
val id: Int,
@SerialName("image_url") val imageUrl: String,
val caption: String? = null,
@SerialName("uploaded_at") val uploadedAt: String? = null
) {
// Alias for backwards compatibility
val image: String get() = imageUrl
}
// Type aliases for backwards compatibility with existing code
typealias CustomTask = TaskResponse
typealias TaskDetail = TaskResponse
typealias TaskCompletion = TaskCompletionResponse

View File

@@ -0,0 +1,161 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WarrantyStatus(
@SerialName("status_text") val statusText: String,
@SerialName("status_color") val statusColor: String,
@SerialName("is_expiring_soon") val isExpiringSoon: Boolean
)
@Serializable
data class DocumentImage(
val id: Int? = null,
@SerialName("image_url") val imageUrl: String,
val caption: String? = null,
@SerialName("uploaded_at") val uploadedAt: String? = null
)
@Serializable
data class Document(
val id: Int? = null,
val title: String,
@SerialName("document_type") val documentType: String,
val category: String? = null,
val description: String? = null,
@SerialName("file_url") val fileUrl: String? = null, // URL to the file
@SerialName("file_size") val fileSize: Int? = null,
@SerialName("file_type") val fileType: String? = null,
// Warranty-specific fields (only used when documentType == "warranty")
@SerialName("item_name") val itemName: String? = null,
@SerialName("model_number") val modelNumber: String? = null,
@SerialName("serial_number") val serialNumber: String? = null,
val provider: String? = null,
@SerialName("provider_contact") val providerContact: String? = null,
@SerialName("claim_phone") val claimPhone: String? = null,
@SerialName("claim_email") val claimEmail: String? = null,
@SerialName("claim_website") val claimWebsite: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("start_date") val startDate: String? = null,
@SerialName("end_date") val endDate: String? = null,
// Relationships
val residence: Int,
@SerialName("residence_address") val residenceAddress: String? = null,
val contractor: Int? = null,
@SerialName("contractor_name") val contractorName: String? = null,
@SerialName("contractor_phone") val contractorPhone: String? = null,
@SerialName("uploaded_by") val uploadedBy: Int? = null,
@SerialName("uploaded_by_username") val uploadedByUsername: String? = null,
// Images
val images: List<DocumentImage> = emptyList(),
// Metadata
val tags: String? = null,
val notes: String? = null,
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("days_until_expiration") val daysUntilExpiration: Int? = null,
@SerialName("warranty_status") val warrantyStatus: WarrantyStatus? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null
)
@Serializable
data class DocumentCreateRequest(
val title: String,
@SerialName("document_type") val documentType: String,
val category: String? = null,
val description: String? = null,
// Note: file will be handled separately as multipart/form-data
// Warranty-specific fields
@SerialName("item_name") val itemName: String? = null,
@SerialName("model_number") val modelNumber: String? = null,
@SerialName("serial_number") val serialNumber: String? = null,
val provider: String? = null,
@SerialName("provider_contact") val providerContact: String? = null,
@SerialName("claim_phone") val claimPhone: String? = null,
@SerialName("claim_email") val claimEmail: String? = null,
@SerialName("claim_website") val claimWebsite: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("start_date") val startDate: String? = null,
@SerialName("end_date") val endDate: String? = null,
// Relationships
@SerialName("residence_id") val residenceId: Int,
@SerialName("contractor_id") val contractorId: Int? = null,
@SerialName("task_id") val taskId: Int? = null,
// Images
@SerialName("image_urls") val imageUrls: List<String>? = null,
// Metadata
val tags: String? = null,
val notes: String? = null,
@SerialName("is_active") val isActive: Boolean = true
)
@Serializable
data class DocumentUpdateRequest(
val title: String? = null,
@SerialName("document_type") val documentType: String? = null,
val category: String? = null,
val description: String? = null,
// Note: file will be handled separately as multipart/form-data
// Warranty-specific fields
@SerialName("item_name") val itemName: String? = null,
@SerialName("model_number") val modelNumber: String? = null,
@SerialName("serial_number") val serialNumber: String? = null,
val provider: String? = null,
@SerialName("provider_contact") val providerContact: String? = null,
@SerialName("claim_phone") val claimPhone: String? = null,
@SerialName("claim_email") val claimEmail: String? = null,
@SerialName("claim_website") val claimWebsite: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("start_date") val startDate: String? = null,
@SerialName("end_date") val endDate: String? = null,
// Relationships
@SerialName("contractor_id") val contractorId: Int? = null,
// Metadata
val tags: String? = null,
val notes: String? = null,
@SerialName("is_active") val isActive: Boolean? = null
)
// Removed: DocumentListResponse - no longer using paginated responses
// API now returns List<Document> directly
// Document type choices
enum class DocumentType(val value: String, val displayName: String) {
WARRANTY("warranty", "Warranty"),
MANUAL("manual", "User Manual"),
RECEIPT("receipt", "Receipt/Invoice"),
INSPECTION("inspection", "Inspection Report"),
PERMIT("permit", "Permit"),
DEED("deed", "Deed/Title"),
INSURANCE("insurance", "Insurance"),
CONTRACT("contract", "Contract"),
PHOTO("photo", "Photo"),
OTHER("other", "Other");
companion object {
fun fromValue(value: String): DocumentType {
return values().find { it.value == value } ?: OTHER
}
}
}
// Document/Warranty category choices
enum class DocumentCategory(val value: String, val displayName: String) {
APPLIANCE("appliance", "Appliance"),
HVAC("hvac", "HVAC"),
PLUMBING("plumbing", "Plumbing"),
ELECTRICAL("electrical", "Electrical"),
ROOFING("roofing", "Roofing"),
STRUCTURAL("structural", "Structural"),
LANDSCAPING("landscaping", "Landscaping"),
GENERAL("general", "General"),
OTHER("other", "Other");
companion object {
fun fromValue(value: String): DocumentCategory {
return values().find { it.value == value } ?: OTHER
}
}
}

View File

@@ -0,0 +1,12 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(
val error: String,
val detail: String,
@SerialName("status_code") val statusCode: Int,
val errors: Map<String, List<String>>? = null
)

View File

@@ -0,0 +1,136 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Residence type lookup - matching Go API
* Note: Go API returns arrays directly, no wrapper
*/
@Serializable
data class ResidenceType(
val id: Int,
val name: String
)
/**
* Task frequency lookup - matching Go API TaskFrequencyResponse
*/
@Serializable
data class TaskFrequency(
val id: Int,
val name: String,
val days: Int? = null,
@SerialName("display_order") val displayOrder: Int = 0
) {
// Helper for display
val displayName: String
get() = name
}
/**
* Task priority lookup - matching Go API TaskPriorityResponse
*/
@Serializable
data class TaskPriority(
val id: Int,
val name: String,
val level: Int = 0,
val color: String = "",
@SerialName("display_order") val displayOrder: Int = 0
) {
// Helper for display
val displayName: String
get() = name
}
/**
* Task status lookup - matching Go API TaskStatusResponse
*/
@Serializable
data class TaskStatus(
val id: Int,
val name: String,
val description: String = "",
val color: String = "",
@SerialName("display_order") val displayOrder: Int = 0
) {
// Helper for display
val displayName: String
get() = name
}
/**
* Task category lookup - matching Go API TaskCategoryResponse
*/
@Serializable
data class TaskCategory(
val id: Int,
val name: String,
val description: String = "",
val icon: String = "",
val color: String = "",
@SerialName("display_order") val displayOrder: Int = 0
)
/**
* Contractor specialty lookup
*/
@Serializable
data class ContractorSpecialty(
val id: Int,
val name: String
)
/**
* Static data response - all lookups in one call
* Note: This may need adjustment based on Go API implementation
*/
@Serializable
data class StaticDataResponse(
@SerialName("residence_types") val residenceTypes: List<ResidenceType>,
@SerialName("task_frequencies") val taskFrequencies: List<TaskFrequency>,
@SerialName("task_priorities") val taskPriorities: List<TaskPriority>,
@SerialName("task_statuses") val taskStatuses: List<TaskStatus>,
@SerialName("task_categories") val taskCategories: List<TaskCategory>,
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>
)
// Legacy wrapper responses for backward compatibility
// These can be removed once all code is migrated to use arrays directly
@Serializable
data class ResidenceTypeResponse(
val count: Int,
val results: List<ResidenceType>
)
@Serializable
data class TaskFrequencyResponse(
val count: Int,
val results: List<TaskFrequency>
)
@Serializable
data class TaskPriorityResponse(
val count: Int,
val results: List<TaskPriority>
)
@Serializable
data class TaskStatusResponse(
val count: Int,
val results: List<TaskStatus>
)
@Serializable
data class TaskCategoryResponse(
val count: Int,
val results: List<TaskCategory>
)
@Serializable
data class ContractorSpecialtyResponse(
val count: Int,
val results: List<ContractorSpecialty>
)

View File

@@ -0,0 +1,85 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DeviceRegistrationRequest(
@SerialName("registration_id")
val registrationId: String,
val platform: String // "android" or "ios"
)
@Serializable
data class DeviceRegistrationResponse(
val id: Int,
@SerialName("registration_id")
val registrationId: String,
val platform: String,
val active: Boolean,
@SerialName("date_created")
val dateCreated: String
)
@Serializable
data class NotificationPreference(
val id: Int,
@SerialName("task_due_soon")
val taskDueSoon: Boolean = true,
@SerialName("task_overdue")
val taskOverdue: Boolean = true,
@SerialName("task_completed")
val taskCompleted: Boolean = true,
@SerialName("task_assigned")
val taskAssigned: Boolean = true,
@SerialName("residence_shared")
val residenceShared: Boolean = true,
@SerialName("warranty_expiring")
val warrantyExpiring: Boolean = true,
@SerialName("created_at")
val createdAt: String,
@SerialName("updated_at")
val updatedAt: String
)
@Serializable
data class UpdateNotificationPreferencesRequest(
@SerialName("task_due_soon")
val taskDueSoon: Boolean? = null,
@SerialName("task_overdue")
val taskOverdue: Boolean? = null,
@SerialName("task_completed")
val taskCompleted: Boolean? = null,
@SerialName("task_assigned")
val taskAssigned: Boolean? = null,
@SerialName("residence_shared")
val residenceShared: Boolean? = null,
@SerialName("warranty_expiring")
val warrantyExpiring: Boolean? = null
)
@Serializable
data class Notification(
val id: Int,
@SerialName("notification_type")
val notificationType: String,
val title: String,
val body: String,
val data: Map<String, String> = emptyMap(),
val sent: Boolean,
@SerialName("sent_at")
val sentAt: String? = null,
val read: Boolean,
@SerialName("read_at")
val readAt: String? = null,
@SerialName("created_at")
val createdAt: String,
@SerialName("task_id")
val taskId: Int? = null
)
@Serializable
data class UnreadCountResponse(
@SerialName("unread_count")
val unreadCount: Int
)

View File

@@ -0,0 +1,256 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* User reference for residence responses - matching Go API ResidenceUserResponse
*/
@Serializable
data class ResidenceUserResponse(
val id: Int,
val username: String,
val email: String,
@SerialName("first_name") val firstName: String = "",
@SerialName("last_name") val lastName: String = ""
) {
val displayName: String
get() = when {
firstName.isNotBlank() && lastName.isNotBlank() -> "$firstName $lastName"
firstName.isNotBlank() -> firstName
lastName.isNotBlank() -> lastName
else -> username
}
}
/**
* Residence response matching Go API ResidenceResponse
*/
@Serializable
data class ResidenceResponse(
val id: Int,
@SerialName("owner_id") val ownerId: Int,
val owner: ResidenceUserResponse? = null,
val users: List<ResidenceUserResponse> = emptyList(),
val name: String,
@SerialName("property_type_id") val propertyTypeId: Int? = null,
@SerialName("property_type") val propertyType: ResidenceType? = null,
@SerialName("street_address") val streetAddress: String = "",
@SerialName("apartment_unit") val apartmentUnit: String = "",
val city: String = "",
@SerialName("state_province") val stateProvince: String = "",
@SerialName("postal_code") val postalCode: String = "",
val country: String = "",
val bedrooms: Int? = null,
val bathrooms: Double? = null,
@SerialName("square_footage") val squareFootage: Int? = null,
@SerialName("lot_size") val lotSize: Double? = null,
@SerialName("year_built") val yearBuilt: Int? = null,
val description: String = "",
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("is_primary") val isPrimary: Boolean = false,
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
) {
// Helper to get owner username
val ownerUsername: String get() = owner?.displayName ?: ""
// Helper to get property type name
val propertyTypeName: String? get() = propertyType?.name
// Backwards compatibility for UI code
// Note: isPrimaryOwner requires comparing with current user - can't be computed here
// UI components should check ownerId == currentUserId instead
// Stub task summary for UI compatibility (Go API doesn't return this per-residence)
val taskSummary: ResidenceTaskSummary get() = ResidenceTaskSummary()
// Stub summary for UI compatibility
val summary: ResidenceSummaryResponse get() = ResidenceSummaryResponse(id = id, name = name)
}
/**
* Residence create request matching Go API CreateResidenceRequest
*/
@Serializable
data class ResidenceCreateRequest(
val name: String,
@SerialName("property_type_id") val propertyTypeId: Int? = null,
@SerialName("street_address") val streetAddress: String? = null,
@SerialName("apartment_unit") val apartmentUnit: String? = null,
val city: String? = null,
@SerialName("state_province") val stateProvince: String? = null,
@SerialName("postal_code") val postalCode: String? = null,
val country: String? = null,
val bedrooms: Int? = null,
val bathrooms: Double? = null,
@SerialName("square_footage") val squareFootage: Int? = null,
@SerialName("lot_size") val lotSize: Double? = null,
@SerialName("year_built") val yearBuilt: Int? = null,
val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("is_primary") val isPrimary: Boolean? = null
)
/**
* Residence update request matching Go API UpdateResidenceRequest
*/
@Serializable
data class ResidenceUpdateRequest(
val name: String? = null,
@SerialName("property_type_id") val propertyTypeId: Int? = null,
@SerialName("street_address") val streetAddress: String? = null,
@SerialName("apartment_unit") val apartmentUnit: String? = null,
val city: String? = null,
@SerialName("state_province") val stateProvince: String? = null,
@SerialName("postal_code") val postalCode: String? = null,
val country: String? = null,
val bedrooms: Int? = null,
val bathrooms: Double? = null,
@SerialName("square_footage") val squareFootage: Int? = null,
@SerialName("lot_size") val lotSize: Double? = null,
@SerialName("year_built") val yearBuilt: Int? = null,
val description: String? = null,
@SerialName("purchase_date") val purchaseDate: String? = null,
@SerialName("purchase_price") val purchasePrice: Double? = null,
@SerialName("is_primary") val isPrimary: Boolean? = null
)
/**
* Share code response matching Go API ShareCodeResponse
*/
@Serializable
data class ShareCodeResponse(
val id: Int,
val code: String,
@SerialName("residence_id") val residenceId: Int,
@SerialName("created_by_id") val createdById: Int,
@SerialName("is_active") val isActive: Boolean,
@SerialName("expires_at") val expiresAt: String?,
@SerialName("created_at") val createdAt: String
)
/**
* Generate share code request
*/
@Serializable
data class GenerateShareCodeRequest(
@SerialName("expires_in_hours") val expiresInHours: Int? = null
)
/**
* Generate share code response matching Go API GenerateShareCodeResponse
*/
@Serializable
data class GenerateShareCodeResponse(
val message: String,
@SerialName("share_code") val shareCode: ShareCodeResponse
)
/**
* Join residence request
*/
@Serializable
data class JoinResidenceRequest(
val code: String
)
/**
* Join residence response matching Go API JoinResidenceResponse
*/
@Serializable
data class JoinResidenceResponse(
val message: String,
val residence: ResidenceResponse
)
/**
* Total summary for dashboard display
*/
@Serializable
data class TotalSummary(
@SerialName("total_residences") val totalResidences: Int = 0,
@SerialName("total_tasks") val totalTasks: Int = 0,
@SerialName("total_pending") val totalPending: Int = 0,
@SerialName("total_overdue") val totalOverdue: Int = 0,
@SerialName("tasks_due_next_week") val tasksDueNextWeek: Int = 0,
@SerialName("tasks_due_next_month") val tasksDueNextMonth: Int = 0
)
/**
* My residences response - list of user's residences
* Go API returns array directly, this wraps for consistency
*/
@Serializable
data class MyResidencesResponse(
val residences: List<ResidenceResponse>,
val summary: TotalSummary = TotalSummary()
)
/**
* Residence summary response for dashboard
*/
@Serializable
data class ResidenceSummaryResponse(
val id: Int = 0,
val name: String = "",
@SerialName("task_count") val taskCount: Int = 0,
@SerialName("pending_count") val pendingCount: Int = 0,
@SerialName("overdue_count") val overdueCount: Int = 0
)
/**
* Task category summary for residence
*/
@Serializable
data class TaskCategorySummary(
val name: String,
@SerialName("display_name") val displayName: String,
val icons: TaskCategoryIcons,
val color: String,
val count: Int
)
/**
* Icons for task category (Android/iOS)
*/
@Serializable
data class TaskCategoryIcons(
val android: String = "",
val ios: String = ""
)
/**
* Task summary per residence (for UI backwards compatibility)
*/
@Serializable
data class ResidenceTaskSummary(
val categories: List<TaskCategorySummary> = emptyList()
)
/**
* Residence users response
*/
@Serializable
data class ResidenceUsersResponse(
val owner: ResidenceUserResponse,
val users: List<ResidenceUserResponse>
)
/**
* Remove user response
*/
@Serializable
data class RemoveUserResponse(
val message: String
)
// Type aliases for backwards compatibility with existing code
typealias Residence = ResidenceResponse
typealias ResidenceShareCode = ShareCodeResponse
typealias ResidenceUser = ResidenceUserResponse
typealias TaskSummary = ResidenceTaskSummary
typealias TaskColumnCategory = TaskCategorySummary

View File

@@ -0,0 +1,71 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SubscriptionStatus(
@SerialName("subscribed_at") val subscribedAt: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("auto_renew") val autoRenew: Boolean = true,
val usage: UsageStats,
val limits: Map<String, TierLimits>, // {"free": {...}, "pro": {...}}
@SerialName("limitations_enabled") val limitationsEnabled: Boolean = false // Master toggle
)
@Serializable
data class UsageStats(
@SerialName("properties_count") val propertiesCount: Int,
@SerialName("tasks_count") val tasksCount: Int,
@SerialName("contractors_count") val contractorsCount: Int,
@SerialName("documents_count") val documentsCount: Int
)
@Serializable
data class TierLimits(
val properties: Int? = null, // null = unlimited
val tasks: Int? = null,
val contractors: Int? = null,
val documents: Int? = null
)
@Serializable
data class UpgradeTriggerData(
val title: String,
val message: String,
@SerialName("promo_html") val promoHtml: String? = null,
@SerialName("button_text") val buttonText: String
)
@Serializable
data class FeatureBenefit(
@SerialName("feature_name") val featureName: String,
@SerialName("free_tier") val freeTier: String,
@SerialName("pro_tier") val proTier: String
)
@Serializable
data class Promotion(
@SerialName("promotion_id") val promotionId: String,
val title: String,
val message: String,
val link: String? = null
)
@Serializable
data class ReceiptVerificationRequest(
@SerialName("receipt_data") val receiptData: String
)
@Serializable
data class PurchaseVerificationRequest(
@SerialName("purchase_token") val purchaseToken: String,
@SerialName("product_id") val productId: String
)
@Serializable
data class VerificationResponse(
val success: Boolean,
val tier: String? = null,
val error: String? = null
)

View File

@@ -0,0 +1,18 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Task completion create request matching Go API CreateTaskCompletionRequest
*/
@Serializable
data class TaskCompletionCreateRequest(
@SerialName("task_id") val taskId: Int,
@SerialName("completed_at") val completedAt: String? = null, // Defaults to now on server
val notes: String? = null,
@SerialName("actual_cost") val actualCost: Double? = null,
val rating: Int? = null, // 1-5 star rating
@SerialName("image_urls") val imageUrls: List<String>? = null // Multiple image URLs
)

View File

@@ -0,0 +1,164 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* User model matching Go API UserResponse/CurrentUserResponse
*/
@Serializable
data class User(
val id: Int,
val username: String,
val email: String,
@SerialName("first_name") val firstName: String = "",
@SerialName("last_name") val lastName: String = "",
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("date_joined") val dateJoined: String,
@SerialName("last_login") val lastLogin: String? = null,
// Profile is included in CurrentUserResponse (/auth/me)
val profile: UserProfile? = null,
// Verified is returned directly in LoginResponse, and also in profile for CurrentUserResponse
@SerialName("verified") private val _verified: Boolean = false
) {
// Computed property for display name
val displayName: String
get() = when {
firstName.isNotBlank() && lastName.isNotBlank() -> "$firstName $lastName"
firstName.isNotBlank() -> firstName
lastName.isNotBlank() -> lastName
else -> username
}
// Check if user is verified - from direct field (login) OR from profile (currentUser)
val isVerified: Boolean
get() = _verified || (profile?.verified ?: false)
// Alias for backwards compatibility
val verified: Boolean
get() = isVerified
}
/**
* User profile model matching Go API UserProfileResponse
*/
@Serializable
data class UserProfile(
val id: Int,
@SerialName("user_id") val userId: Int,
val verified: Boolean = false,
val bio: String = "",
@SerialName("phone_number") val phoneNumber: String = "",
@SerialName("date_of_birth") val dateOfBirth: String? = null,
@SerialName("profile_picture") val profilePicture: String = ""
)
/**
* Register request matching Go API
*/
@Serializable
data class RegisterRequest(
val username: String,
val email: String,
val password: String,
@SerialName("first_name") val firstName: String? = null,
@SerialName("last_name") val lastName: String? = null
)
/**
* Login request matching Go API
*/
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
/**
* Auth response for login - matching Go API LoginResponse
*/
@Serializable
data class AuthResponse(
val token: String,
val user: User
)
/**
* Auth response for registration - matching Go API RegisterResponse
*/
@Serializable
data class RegisterResponse(
val token: String,
val user: User,
val message: String = ""
)
/**
* Verify email request
*/
@Serializable
data class VerifyEmailRequest(
val code: String
)
/**
* Verify email response
*/
@Serializable
data class VerifyEmailResponse(
val message: String,
val verified: Boolean
)
/**
* Update profile request
*/
@Serializable
data class UpdateProfileRequest(
@SerialName("first_name") val firstName: String? = null,
@SerialName("last_name") val lastName: String? = null,
val email: String? = null
)
// Password Reset Models
@Serializable
data class ForgotPasswordRequest(
val email: String
)
@Serializable
data class ForgotPasswordResponse(
val message: String
)
@Serializable
data class VerifyResetCodeRequest(
val email: String,
val code: String
)
@Serializable
data class VerifyResetCodeResponse(
val message: String,
@SerialName("reset_token") val resetToken: String
)
@Serializable
data class ResetPasswordRequest(
@SerialName("reset_token") val resetToken: String,
@SerialName("new_password") val newPassword: String
)
@Serializable
data class ResetPasswordResponse(
val message: String
)
/**
* Generic message response used by many endpoints
*/
@Serializable
data class MessageResponse(
val message: String
)

View File

@@ -0,0 +1,117 @@
package com.example.casera.navigation
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
object LoginRoute
@Serializable
object RegisterRoute
@Serializable
object VerifyEmailRoute
@Serializable
object HomeRoute
@Serializable
object ResidencesRoute
@Serializable
object AddResidenceRoute
@Serializable
data class EditResidenceRoute(
val residenceId: Int,
val name: String,
val propertyType: Int?,
val streetAddress: String?,
val apartmentUnit: String?,
val city: String?,
val stateProvince: String?,
val postalCode: String?,
val country: String?,
val bedrooms: Int?,
val bathrooms: Float?,
val squareFootage: Int?,
val lotSize: Float?,
val yearBuilt: Int?,
val description: String?,
val isPrimary: Boolean,
val ownerUserName: String,
val createdAt: String,
val updatedAt: String,
val owner: Int?,
)
@Serializable
data class ResidenceDetailRoute(val residenceId: Int)
@Serializable
data class EditTaskRoute(
val taskId: Int,
val residenceId: Int,
val title: String,
val description: String?,
val categoryId: Int,
val categoryName: String,
val frequencyId: Int,
val frequencyName: String,
val priorityId: Int,
val priorityName: String,
val statusId: Int?,
val statusName: String?,
val dueDate: String?,
val estimatedCost: String?,
val createdAt: String,
val updatedAt: String
)
@Serializable
object TasksRoute
@Serializable
object ProfileRoute
@Serializable
object MainRoute
@Serializable
object MainTabResidencesRoute
@Serializable
object MainTabTasksRoute
@Serializable
object MainTabProfileRoute
@Serializable
object MainTabContractorsRoute
@Serializable
data class ContractorDetailRoute(val contractorId: Int)
@Serializable
object MainTabDocumentsRoute
@Serializable
data class AddDocumentRoute(
val residenceId: Int,
val initialDocumentType: String = "other"
)
@Serializable
data class DocumentDetailRoute(val documentId: Int)
@Serializable
data class EditDocumentRoute(val documentId: Int)
@Serializable
object ForgotPasswordRoute
@Serializable
object VerifyResetCodeRoute
@Serializable
object ResetPasswordRoute

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
package com.example.casera.network
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
expect fun getLocalhostAddress(): String
expect fun createHttpClient(): HttpClient
object ApiClient {
val httpClient = createHttpClient()
/**
* Get the current base URL based on environment configuration.
* To change environment, update ApiConfig.CURRENT_ENV
*/
fun getBaseUrl(): String = ApiConfig.getBaseUrl()
/**
* Get the media base URL (without /api suffix) for serving media files
*/
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
/**
* Print current environment configuration
*/
init {
println("🌐 API Client initialized")
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
println("🔗 Base URL: ${getBaseUrl()}")
println("📁 Media URL: ${getMediaBaseUrl()}")
}
}

View File

@@ -0,0 +1,48 @@
package com.example.casera.network
/**
* API Environment Configuration
*
* To switch between localhost and dev server, simply change the CURRENT_ENV value:
* - Environment.LOCAL for local development
* - Environment.DEV for remote dev server
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.LOCAL
enum class Environment {
LOCAL,
DEV
}
/**
* Get the base URL based on current environment and platform
*/
fun getBaseUrl(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "http://${getLocalhostAddress()}:8000/api"
Environment.DEV -> "https://mycrib.treytartt.com/api"
}
}
/**
* Get the media base URL (without /api suffix) for serving media files
*/
fun getMediaBaseUrl(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "http://${getLocalhostAddress()}:8000"
Environment.DEV -> "https://mycrib.treytartt.com"
}
}
/**
* Get environment name for logging
*/
fun getEnvironmentName(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)"
Environment.DEV -> "Dev Server (mycrib.treytartt.com)"
}
}
}

View File

@@ -0,0 +1,8 @@
package com.example.casera.network
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
object Idle : ApiResult<Nothing>()
}

View File

@@ -0,0 +1,203 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
return try {
val response = client.post("$baseUrl/auth/register/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Registration failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
return try {
val response = client.post("$baseUrl/auth/login/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Login failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun logout(token: String): ApiResult<Unit> {
return try {
val response = client.post("$baseUrl/auth/logout/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Logout failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getCurrentUser(token: String): ApiResult<User> {
return try {
val response = client.get("$baseUrl/auth/me/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get user", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
return try {
val response = client.post("$baseUrl/auth/verify/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Verification failed")
}
ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
return try {
val response = client.put("$baseUrl/auth/update-profile/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Profile update failed")
}
ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Password Reset Methods
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
return try {
val response = client.post("$baseUrl/auth/forgot-password/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Failed to send reset code")
}
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
return try {
val response = client.post("$baseUrl/auth/verify-reset-code/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Invalid code")
}
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
return try {
val response = client.post("$baseUrl/auth/reset-password/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
// Try to parse Django validation errors (Map<String, List<String>>)
val errorMessage = try {
val validationErrors = response.body<Map<String, List<String>>>()
// Flatten all error messages into a single string
validationErrors.flatMap { (field, errors) ->
errors.map { error ->
if (field == "non_field_errors") error else "$field: $error"
}
}.joinToString(". ")
} catch (e: Exception) {
// Try simple error format {error: "message"}
try {
val simpleError = response.body<Map<String, String>>()
simpleError["error"] ?: "Failed to reset password"
} catch (e2: Exception) {
"Failed to reset password"
}
}
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,147 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getContractors(
token: String,
specialty: String? = null,
isFavorite: Boolean? = null,
isActive: Boolean? = null,
search: String? = null
): ApiResult<List<ContractorSummary>> {
return try {
val response = client.get("$baseUrl/contractors/") {
header("Authorization", "Token $token")
specialty?.let { parameter("specialty", it) }
isFavorite?.let { parameter("is_favorite", it) }
isActive?.let { parameter("is_active", it) }
search?.let { parameter("search", it) }
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch contractors", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> {
return try {
val response = client.get("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch contractor", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> {
return try {
val response = client.post("$baseUrl/contractors/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<String>()
} catch (e: Exception) {
"Failed to create contractor"
}
ApiResult.Error(errorBody, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> {
return try {
val response = client.patch("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<String>()
} catch (e: Exception) {
"Failed to update contractor"
}
ApiResult.Error(errorBody, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to delete contractor", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
return try {
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to toggle favorite", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> {
return try {
val response = client.get("$baseUrl/contractors/$id/tasks/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch contractor tasks", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,415 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.utils.io.core.*
class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getDocuments(
token: String,
residenceId: Int? = null,
documentType: String? = null,
category: String? = null,
contractorId: Int? = null,
isActive: Boolean? = null,
expiringSoon: Int? = null,
tags: String? = null,
search: String? = null
): ApiResult<List<Document>> {
return try {
val response = client.get("$baseUrl/documents/") {
header("Authorization", "Token $token")
residenceId?.let { parameter("residence", it) }
documentType?.let { parameter("document_type", it) }
category?.let { parameter("category", it) }
contractorId?.let { parameter("contractor", it) }
isActive?.let { parameter("is_active", it) }
expiringSoon?.let { parameter("expiring_soon", it) }
tags?.let { parameter("tags", it) }
search?.let { parameter("search", it) }
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch documents", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getDocument(token: String, id: Int): ApiResult<Document> {
return try {
val response = client.get("$baseUrl/documents/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch document", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun createDocument(
token: String,
title: String,
documentType: String,
residenceId: Int,
description: String? = null,
category: String? = null,
tags: String? = null,
notes: String? = null,
contractorId: Int? = null,
isActive: Boolean = true,
// Warranty-specific fields
itemName: String? = null,
modelNumber: String? = null,
serialNumber: String? = null,
provider: String? = null,
providerContact: String? = null,
claimPhone: String? = null,
claimEmail: String? = null,
claimWebsite: String? = null,
purchaseDate: String? = null,
startDate: String? = null,
endDate: String? = null,
// File (optional for warranties) - kept for backwards compatibility
fileBytes: ByteArray? = null,
fileName: String? = null,
mimeType: String? = null,
// Multiple files support
fileBytesList: List<ByteArray>? = null,
fileNamesList: List<String>? = null,
mimeTypesList: List<String>? = null
): ApiResult<Document> {
return try {
val response = if ((fileBytesList != null && fileBytesList.isNotEmpty() && fileNamesList != null && mimeTypesList != null) ||
(fileBytes != null && fileName != null && mimeType != null)) {
// If files are provided, use multipart/form-data
client.submitFormWithBinaryData(
url = "$baseUrl/documents/",
formData = formData {
append("title", title)
append("document_type", documentType)
append("residence_id", residenceId.toString())
description?.let { append("description", it) }
category?.let { append("category", it) }
tags?.let { append("tags", it) }
notes?.let { append("notes", it) }
contractorId?.let { append("contractor_id", it.toString()) }
append("is_active", isActive.toString())
// Warranty fields
itemName?.let { append("item_name", it) }
modelNumber?.let { append("model_number", it) }
serialNumber?.let { append("serial_number", it) }
provider?.let { append("provider", it) }
providerContact?.let { append("provider_contact", it) }
claimPhone?.let { append("claim_phone", it) }
claimEmail?.let { append("claim_email", it) }
claimWebsite?.let { append("claim_website", it) }
purchaseDate?.let { append("purchase_date", it) }
startDate?.let { append("start_date", it) }
endDate?.let { append("end_date", it) }
// Handle multiple files if provided
if (fileBytesList != null && fileBytesList.isNotEmpty() && fileNamesList != null && mimeTypesList != null) {
fileBytesList.forEachIndexed { index, bytes ->
append("files", bytes, Headers.build {
append(HttpHeaders.ContentType, mimeTypesList.getOrElse(index) { "application/octet-stream" })
append(HttpHeaders.ContentDisposition, "filename=\"${fileNamesList.getOrElse(index) { "file_$index" }}\"")
})
}
} else if (fileBytes != null && fileName != null && mimeType != null) {
// Single file (backwards compatibility)
append("file", fileBytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
})
}
}
) {
header("Authorization", "Token $token")
}
} else {
// If no file, use JSON
val request = DocumentCreateRequest(
title = title,
documentType = documentType,
category = category,
description = description,
itemName = itemName,
modelNumber = modelNumber,
serialNumber = serialNumber,
provider = provider,
providerContact = providerContact,
claimPhone = claimPhone,
claimEmail = claimEmail,
claimWebsite = claimWebsite,
purchaseDate = purchaseDate,
startDate = startDate,
endDate = endDate,
residenceId = residenceId,
contractorId = contractorId,
tags = tags,
notes = notes,
isActive = isActive
)
client.post("$baseUrl/documents/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<String>()
} catch (e: Exception) {
"Failed to create document"
}
ApiResult.Error(errorBody, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateDocument(
token: String,
id: Int,
title: String? = null,
documentType: String? = null,
description: String? = null,
category: String? = null,
tags: String? = null,
notes: String? = null,
contractorId: Int? = null,
isActive: Boolean? = null,
// Warranty-specific fields
itemName: String? = null,
modelNumber: String? = null,
serialNumber: String? = null,
provider: String? = null,
providerContact: String? = null,
claimPhone: String? = null,
claimEmail: String? = null,
claimWebsite: String? = null,
purchaseDate: String? = null,
startDate: String? = null,
endDate: String? = null,
// File
fileBytes: ByteArray? = null,
fileName: String? = null,
mimeType: String? = null
): ApiResult<Document> {
return try {
// If file is being updated, use multipart/form-data
val response = if (fileBytes != null && fileName != null && mimeType != null) {
client.submitFormWithBinaryData(
url = "$baseUrl/documents/$id/",
formData = formData {
title?.let { append("title", it) }
documentType?.let { append("document_type", it) }
description?.let { append("description", it) }
category?.let { append("category", it) }
tags?.let { append("tags", it) }
notes?.let { append("notes", it) }
contractorId?.let { append("contractor_id", it.toString()) }
isActive?.let { append("is_active", it.toString()) }
// Warranty fields
itemName?.let { append("item_name", it) }
modelNumber?.let { append("model_number", it) }
serialNumber?.let { append("serial_number", it) }
provider?.let { append("provider", it) }
providerContact?.let { append("provider_contact", it) }
claimPhone?.let { append("claim_phone", it) }
claimEmail?.let { append("claim_email", it) }
claimWebsite?.let { append("claim_website", it) }
purchaseDate?.let { append("purchase_date", it) }
startDate?.let { append("start_date", it) }
endDate?.let { append("end_date", it) }
append("file", fileBytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
})
}
) {
header("Authorization", "Token $token")
method = HttpMethod.Put
}
} else {
// Otherwise use JSON for metadata-only updates
val request = DocumentUpdateRequest(
title = title,
documentType = documentType,
category = category,
description = description,
itemName = itemName,
modelNumber = modelNumber,
serialNumber = serialNumber,
provider = provider,
providerContact = providerContact,
claimPhone = claimPhone,
claimEmail = claimEmail,
claimWebsite = claimWebsite,
purchaseDate = purchaseDate,
startDate = startDate,
endDate = endDate,
contractorId = contractorId,
tags = tags,
notes = notes,
isActive = isActive
)
client.patch("$baseUrl/documents/$id/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<String>()
} catch (e: Exception) {
"Failed to update document"
}
ApiResult.Error(errorBody, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/documents/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to delete document", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> {
return try {
val response = client.get(url) {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to download document", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun activateDocument(token: String, id: Int): ApiResult<Document> {
return try {
val response = client.post("$baseUrl/documents/$id/activate/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to activate document", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> {
return try {
val response = client.post("$baseUrl/documents/$id/deactivate/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to deactivate document", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deleteDocumentImage(token: String, imageId: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/document-images/$imageId/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to delete image", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun uploadDocumentImage(
token: String,
documentId: Int,
imageBytes: ByteArray,
fileName: String = "image.jpg",
mimeType: String = "image/jpeg",
caption: String? = null
): ApiResult<DocumentImage> {
return try {
val response = client.submitFormWithBinaryData(
url = "$baseUrl/document-images/",
formData = formData {
append("document", documentId.toString())
caption?.let { append("caption", it) }
append("image", imageBytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
})
}
) {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<String>()
} catch (e: Exception) {
"Failed to upload image"
}
ApiResult.Error(errorBody, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,42 @@
package com.example.casera.network
import com.example.casera.models.ErrorResponse
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
import kotlinx.serialization.json.Json
object ErrorParser {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
suspend fun parseError(response: HttpResponse): String {
return try {
val errorResponse = response.body<ErrorResponse>()
// Build detailed error message
val message = StringBuilder()
message.append(errorResponse.detail)
// Add field-specific errors if present
errorResponse.errors?.let { fieldErrors ->
if (fieldErrors.isNotEmpty()) {
message.append("\n\nDetails:")
fieldErrors.forEach { (field, errors) ->
message.append("\n$field: ${errors.joinToString(", ")}")
}
}
}
message.toString()
} catch (e: Exception) {
// Fallback to reading as plain text
try {
response.body<String>()
} catch (e: Exception) {
"An error occurred (${response.status.value})"
}
}
}
}

View File

@@ -0,0 +1,139 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
return try {
val response = client.get("$baseUrl/residence-types/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch residence types", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
return try {
val response = client.get("$baseUrl/task-frequencies/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch task frequencies", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
return try {
val response = client.get("$baseUrl/task-priorities/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch task priorities", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getTaskStatuses(token: String): ApiResult<List<TaskStatus>> {
return try {
val response = client.get("$baseUrl/task-statuses/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch task statuses", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
return try {
val response = client.get("$baseUrl/task-categories/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch task categories", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
return try {
val response = client.get("$baseUrl/contractor-specialties/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch contractor specialties", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getAllTasks(token: String): ApiResult<List<TaskResponse>> {
return try {
val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch tasks", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getStaticData(token: String): ApiResult<StaticDataResponse> {
return try {
val response = client.get("$baseUrl/static_data/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch static data", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,186 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
/**
* Register a device for push notifications
*/
suspend fun registerDevice(
token: String,
request: DeviceRegistrationRequest
): ApiResult<DeviceRegistrationResponse> {
return try {
val response = client.post("$baseUrl/notifications/devices/register/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Device registration failed")
}
ApiResult.Error(errorBody["error"] ?: "Device registration failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Unregister a device
*/
suspend fun unregisterDevice(
token: String,
registrationId: String
): ApiResult<Unit> {
return try {
val response = client.post("$baseUrl/notifications/devices/unregister/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(mapOf("registration_id" to registrationId))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Device unregistration failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get user's notification preferences
*/
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
return try {
val response = client.get("$baseUrl/notifications/preferences/my_preferences/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get preferences", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Update notification preferences
*/
suspend fun updateNotificationPreferences(
token: String,
request: UpdateNotificationPreferencesRequest
): ApiResult<NotificationPreference> {
return try {
val response = client.put("$baseUrl/notifications/preferences/update_preferences/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to update preferences", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get notification history
*/
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
return try {
val response = client.get("$baseUrl/notifications/history/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get notification history", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Mark a notification as read
*/
suspend fun markNotificationAsRead(
token: String,
notificationId: Int
): ApiResult<Notification> {
return try {
val response = client.post("$baseUrl/notifications/history/$notificationId/mark_as_read/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to mark notification as read", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Mark all notifications as read
*/
suspend fun markAllNotificationsAsRead(token: String): ApiResult<Map<String, Int>> {
return try {
val response = client.post("$baseUrl/notifications/history/mark_all_as_read/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to mark all notifications as read", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get unread notification count
*/
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
return try {
val response = client.get("$baseUrl/notifications/history/unread_count/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to get unread count", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,248 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
return try {
val response = client.get("$baseUrl/residences/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch residences", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> {
return try {
val response = client.get("$baseUrl/residences/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch residence", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<ResidenceResponse> {
return try {
val response = client.post("$baseUrl/residences/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to create residence", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<ResidenceResponse> {
return try {
val response = client.put("$baseUrl/residences/$id/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to update residence", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deleteResidence(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/residences/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to delete residence", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getResidenceSummary(token: String): ApiResult<ResidenceSummaryResponse> {
return try {
val response = client.get("$baseUrl/residences/summary/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch residence summary", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> {
return try {
val response = client.get("$baseUrl/residences/my-residences/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch my residences", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Share Code Management
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> {
return try {
val response = client.get("$baseUrl/residences/$residenceId/share-code/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
return try {
val response = client.post("$baseUrl/residences/join-with-code/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(JoinResidenceRequest(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// User Management
suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> {
return try {
val response = client.get("$baseUrl/residences/$residenceId/users/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
return try {
val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// PDF Report Generation
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
if (email != null) {
setBody(mapOf("email" to email))
}
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}
@kotlinx.serialization.Serializable
data class GenerateReportResponse(
val message: String,
val residence_name: String,
val recipient_email: String
)
// Removed: PaginatedResponse - no longer using paginated responses
// All API endpoints now return direct lists instead of paginated responses

View File

@@ -0,0 +1,123 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
return try {
val response = client.get("$baseUrl/subscription/status/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch subscription status", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getUpgradeTriggers(token: String): ApiResult<Map<String, UpgradeTriggerData>> {
return try {
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch upgrade triggers", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
return try {
val response = client.get("$baseUrl/subscription/feature-benefits/")
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch feature benefits", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
return try {
val response = client.get("$baseUrl/subscription/promotions/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch promotions", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyIOSReceipt(
token: String,
receiptData: String,
transactionId: String
): ApiResult<VerificationResponse> {
return try {
val response = client.post("$baseUrl/subscription/verify-ios/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(mapOf(
"receipt_data" to receiptData,
"transaction_id" to transactionId
))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to verify iOS receipt", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyAndroidPurchase(
token: String,
purchaseToken: String,
productId: String
): ApiResult<VerificationResponse> {
return try {
val response = client.post("$baseUrl/subscription/verify-android/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(mapOf(
"purchase_token" to purchaseToken,
"product_id" to productId
))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to verify Android purchase", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,197 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getTasks(
token: String,
days: Int? = null
): ApiResult<TaskColumnsResponse> {
return try {
val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token")
days?.let { parameter("days", it) }
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> {
return try {
val response = client.get("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<TaskResponse> {
return try {
val response = client.post("$baseUrl/tasks/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<TaskResponse> {
return try {
val response = client.put("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deleteTask(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getTasksByResidence(
token: String,
residenceId: Int,
days: Int? = null
): ApiResult<TaskColumnsResponse> {
return try {
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
header("Authorization", "Token $token")
days?.let { parameter("days", it) }
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Generic PATCH method for partial task updates.
* Used for status changes and archive/unarchive operations.
*
* NOTE: The old custom action endpoints (cancel, uncancel, mark-in-progress,
* archive, unarchive) have been REMOVED from the API.
* All task updates now use PATCH /tasks/{id}/.
*/
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<TaskResponse> {
return try {
val response = client.patch("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// DEPRECATED: These methods now use PATCH internally.
// They're kept for backward compatibility with existing ViewModel calls.
// New code should use patchTask directly with status IDs from DataCache.
suspend fun cancelTask(token: String, id: Int, cancelledStatusId: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId))
}
suspend fun uncancelTask(token: String, id: Int, pendingStatusId: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(status = pendingStatusId))
}
suspend fun markInProgress(token: String, id: Int, inProgressStatusId: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(status = inProgressStatusId))
}
suspend fun archiveTask(token: String, id: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(archived = true))
}
suspend fun unarchiveTask(token: String, id: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(archived = false))
}
/**
* Get all completions for a specific task
*/
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
return try {
val response = client.get("$baseUrl/tasks/$taskId/completions/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,142 @@
package com.example.casera.network
import com.example.casera.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
return try {
val response = client.get("$baseUrl/task-completions/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch completions", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> {
return try {
val response = client.get("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch completion", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
return try {
val response = client.post("$baseUrl/task-completions/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to create completion", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
return try {
val response = client.put("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to update completion", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun deleteCompletion(token: String, id: Int): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.Error("Failed to delete completion", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun createCompletionWithImages(
token: String,
request: TaskCompletionCreateRequest,
images: List<ByteArray> = emptyList(),
imageFileNames: List<String> = emptyList()
): ApiResult<TaskCompletionResponse> {
return try {
val response = client.post("$baseUrl/task-completions/") {
header("Authorization", "Token $token")
setBody(
io.ktor.client.request.forms.MultiPartFormDataContent(
io.ktor.client.request.forms.formData {
// Add text fields
append("task_id", request.taskId.toString())
request.completedAt?.let { append("completed_at", it) }
request.actualCost?.let { append("actual_cost", it.toString()) }
request.notes?.let { append("notes", it) }
request.rating?.let { append("rating", it.toString()) }
// Add image files
images.forEachIndexed { index, imageBytes ->
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
append(
"images",
imageBytes,
io.ktor.http.Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
}
)
}
}
)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to create completion with images", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,36 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
data class ImageData(
val bytes: ByteArray,
val fileName: String
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as ImageData
if (!bytes.contentEquals(other.bytes)) return false
if (fileName != other.fileName) return false
return true
}
override fun hashCode(): Int {
var result = bytes.contentHashCode()
result = 31 * result + fileName.hashCode()
return result
}
}
@Composable
expect fun rememberImagePicker(
onImagesPicked: (List<ImageData>) -> Unit
): () -> Unit
@Composable
expect fun rememberCameraPicker(
onImageCaptured: (ImageData) -> Unit
): () -> Unit

View File

@@ -0,0 +1,175 @@
package com.example.casera.repository
import com.example.casera.cache.SubscriptionCache
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import com.example.casera.network.LookupsApi
import com.example.casera.network.SubscriptionApi
import com.example.casera.storage.TokenStorage
import com.example.casera.storage.TaskCacheStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Singleton repository for managing lookup data across the entire app.
* Fetches data once on initialization and caches it for the app session.
*/
object LookupsRepository {
private val lookupsApi = LookupsApi()
private val subscriptionApi = SubscriptionApi()
private val scope = CoroutineScope(Dispatchers.Default)
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties
private val _allTasks = MutableStateFlow<List<CustomTask>>(emptyList())
val allTasks: StateFlow<List<CustomTask>> = _allTasks
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized
/**
* Load all lookups from the API.
* This should be called once when the user logs in.
*/
fun initialize() {
// Only initialize once per app session
if (_isInitialized.value) {
return
}
scope.launch {
_isLoading.value = true
// Load cached tasks from disk immediately for offline access
val cachedTasks = TaskCacheStorage.getTasks()
if (cachedTasks != null) {
_allTasks.value = cachedTasks
println("Loaded ${cachedTasks.size} tasks from cache")
}
val token = TokenStorage.getToken()
if (token != null) {
// Load all static data in a single API call
launch {
when (val result = lookupsApi.getStaticData(token)) {
is ApiResult.Success -> {
_residenceTypes.value = result.data.residenceTypes
_taskFrequencies.value = result.data.taskFrequencies
_taskPriorities.value = result.data.taskPriorities
_taskStatuses.value = result.data.taskStatuses
_taskCategories.value = result.data.taskCategories
_contractorSpecialties.value = result.data.contractorSpecialties
println("Loaded all static data successfully")
}
else -> {
println("Failed to fetch static data")
}
}
}
launch {
when (val result = lookupsApi.getAllTasks(token)) {
is ApiResult.Success -> {
_allTasks.value = result.data
// Save to disk cache for offline access
TaskCacheStorage.saveTasks(result.data)
println("Fetched and cached ${result.data.size} tasks from API")
}
else -> {
println("Failed to fetch tasks from API, using cached data if available")
}
}
}
// Load subscription status for limitation checks
launch {
println("🔄 [LookupsRepository] Fetching subscription status...")
when (val result = subscriptionApi.getSubscriptionStatus(token)) {
is ApiResult.Success -> {
println("✅ [LookupsRepository] Subscription status loaded: limitationsEnabled=${result.data.limitationsEnabled}")
println(" Limits: ${result.data.limits}")
SubscriptionCache.updateSubscriptionStatus(result.data)
}
is ApiResult.Error -> {
println("❌ [LookupsRepository] Failed to fetch subscription status: ${result.message}")
}
else -> {
println("❌ [LookupsRepository] Unexpected subscription result")
}
}
}
// Load upgrade triggers for subscription prompts
launch {
println("🔄 [LookupsRepository] Fetching upgrade triggers...")
when (val result = subscriptionApi.getUpgradeTriggers(token)) {
is ApiResult.Success -> {
println("✅ [LookupsRepository] Upgrade triggers loaded: ${result.data.size} triggers")
SubscriptionCache.updateUpgradeTriggers(result.data)
}
is ApiResult.Error -> {
println("❌ [LookupsRepository] Failed to fetch upgrade triggers: ${result.message}")
}
else -> {
println("❌ [LookupsRepository] Unexpected upgrade triggers result")
}
}
}
}
_isInitialized.value = true
_isLoading.value = false
}
}
/**
* Clear all cached data.
* This should be called when the user logs out.
*/
fun clear() {
_residenceTypes.value = emptyList()
_taskFrequencies.value = emptyList()
_taskPriorities.value = emptyList()
_taskStatuses.value = emptyList()
_taskCategories.value = emptyList()
_contractorSpecialties.value = emptyList()
_allTasks.value = emptyList()
// Clear disk cache on logout
TaskCacheStorage.clearTasks()
// Clear subscription cache on logout
SubscriptionCache.clear()
_isInitialized.value = false
_isLoading.value = false
}
/**
* Force refresh all lookups from the API.
*/
fun refresh() {
_isInitialized.value = false
initialize()
}
}

View File

@@ -0,0 +1,12 @@
package com.example.casera.storage
/**
* Platform-specific task cache manager interface for persistent storage.
* Each platform implements this using their native storage mechanisms.
*/
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect class TaskCacheManager {
fun saveTasks(tasksJson: String)
fun getTasks(): String?
fun clearTasks()
}

View File

@@ -0,0 +1,66 @@
package com.example.casera.storage
import com.example.casera.models.CustomTask
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
/**
* Task cache storage that provides a unified interface for accessing platform-specific
* persistent storage. This allows tasks to persist across app restarts for offline access.
*/
object TaskCacheStorage {
private var cacheManager: TaskCacheManager? = null
private val json = Json { ignoreUnknownKeys = true }
/**
* Initialize TaskCacheStorage with a platform-specific TaskCacheManager.
* This should be called once during app initialization.
*/
fun initialize(manager: TaskCacheManager) {
cacheManager = manager
}
private fun ensureInitialized() {
if (cacheManager == null) {
cacheManager = getPlatformTaskCacheManager()
}
}
fun saveTasks(tasks: List<CustomTask>) {
ensureInitialized()
try {
val tasksJson = json.encodeToString(tasks)
cacheManager?.saveTasks(tasksJson)
} catch (e: Exception) {
println("Error saving tasks to cache: ${e.message}")
}
}
fun getTasks(): List<CustomTask>? {
ensureInitialized()
return try {
val tasksJson = cacheManager?.getTasks()
if (tasksJson != null) {
json.decodeFromString<List<CustomTask>>(tasksJson)
} else {
null
}
} catch (e: Exception) {
println("Error loading tasks from cache: ${e.message}")
null
}
}
fun clearTasks() {
ensureInitialized()
cacheManager?.clearTasks()
}
}
/**
* Platform-specific function to get the default TaskCacheManager instance.
* For platforms that don't require context (web, iOS, JVM), returns singleton.
* For Android, must be initialized via initialize() method before use.
*/
internal expect fun getPlatformTaskCacheManager(): TaskCacheManager?

View File

@@ -0,0 +1,35 @@
package com.example.casera.storage
/**
* Cross-platform theme storage for persisting theme selection.
* Uses platform-specific implementations (SharedPreferences on Android, UserDefaults on iOS).
*/
object ThemeStorage {
private var manager: ThemeStorageManager? = null
fun initialize(themeManager: ThemeStorageManager) {
manager = themeManager
}
fun saveThemeId(themeId: String) {
manager?.saveThemeId(themeId)
}
fun getThemeId(): String? {
return manager?.getThemeId()
}
fun clearThemeId() {
manager?.clearThemeId()
}
}
/**
* Platform-specific theme storage interface.
* Each platform implements this using their native storage mechanisms.
*/
expect class ThemeStorageManager {
fun saveThemeId(themeId: String)
fun getThemeId(): String?
fun clearThemeId()
}

View File

@@ -0,0 +1,12 @@
package com.example.casera.storage
/**
* Platform-specific token manager interface for persistent storage.
* Each platform implements this using their native storage mechanisms.
*/
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect class TokenManager {
fun saveToken(token: String)
fun getToken(): String?
fun clearToken()
}

View File

@@ -0,0 +1,57 @@
package com.example.casera.storage
/**
* Token storage that provides a unified interface for accessing platform-specific
* persistent storage. This allows tokens to persist across app restarts.
*/
object TokenStorage {
private var tokenManager: TokenManager? = null
private var cachedToken: String? = null
/**
* Initialize TokenStorage with a platform-specific TokenManager.
* This should be called once during app initialization.
*/
fun initialize(manager: TokenManager) {
tokenManager = manager
// Load cached token from persistent storage
cachedToken = manager.getToken()
}
private fun ensureInitialized() {
if (tokenManager == null) {
tokenManager = getPlatformTokenManager()
cachedToken = tokenManager?.getToken()
}
}
fun saveToken(token: String) {
ensureInitialized()
cachedToken = token
tokenManager?.saveToken(token)
}
fun getToken(): String? {
ensureInitialized()
// Return cached token if available, otherwise try to load from storage
if (cachedToken == null) {
cachedToken = tokenManager?.getToken()
}
return cachedToken
}
fun clearToken() {
ensureInitialized()
cachedToken = null
tokenManager?.clearToken()
}
fun hasToken(): Boolean = getToken() != null
}
/**
* Platform-specific function to get the default TokenManager instance.
* For platforms that don't require context (web, iOS, JVM), returns singleton.
* For Android, must be initialized via initialize() method before use.
*/
internal expect fun getPlatformTokenManager(): TokenManager?

View File

@@ -0,0 +1,473 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.ContractorCreateRequest
import com.example.casera.models.ContractorUpdateRequest
import com.example.casera.network.ApiResult
import com.example.casera.repository.LookupsRepository
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddContractorDialog(
contractorId: Int? = null,
onDismiss: () -> Unit,
onContractorSaved: () -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
val createState by viewModel.createState.collectAsState()
val updateState by viewModel.updateState.collectAsState()
val contractorDetailState by viewModel.contractorDetailState.collectAsState()
var name by remember { mutableStateOf("") }
var company by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var secondaryPhone by remember { mutableStateOf("") }
var specialty by remember { mutableStateOf("") }
var licenseNumber by remember { mutableStateOf("") }
var website by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var city by remember { mutableStateOf("") }
var state by remember { mutableStateOf("") }
var zipCode by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var isFavorite by remember { mutableStateOf(false) }
var expandedSpecialtyMenu by remember { mutableStateOf(false) }
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
val specialties = contractorSpecialties.map { it.name }
// Load existing contractor data if editing
LaunchedEffect(contractorId) {
if (contractorId != null) {
viewModel.loadContractorDetail(contractorId)
}
}
LaunchedEffect(contractorDetailState) {
if (contractorDetailState is ApiResult.Success) {
val contractor = (contractorDetailState as ApiResult.Success).data
name = contractor.name
company = contractor.company ?: ""
phone = contractor.phone ?: ""
email = contractor.email ?: ""
secondaryPhone = contractor.secondaryPhone ?: ""
specialty = contractor.specialty ?: ""
licenseNumber = contractor.licenseNumber ?: ""
website = contractor.website ?: ""
address = contractor.address ?: ""
city = contractor.city ?: ""
state = contractor.state ?: ""
zipCode = contractor.zipCode ?: ""
notes = contractor.notes ?: ""
isFavorite = contractor.isFavorite
}
}
LaunchedEffect(createState) {
if (createState is ApiResult.Success) {
onContractorSaved()
viewModel.resetCreateState()
}
}
LaunchedEffect(updateState) {
if (updateState is ApiResult.Success) {
onContractorSaved()
viewModel.resetUpdateState()
}
}
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.fillMaxWidth(0.95f),
title = {
Text(
if (contractorId == null) "Add Contractor" else "Edit Contractor",
fontWeight = FontWeight.Bold
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Basic Information Section
Text(
"Basic Information",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Person, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = company,
onValueChange = { company = it },
label = { Text("Company") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Business, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Contact Information Section
Text(
"Contact Information",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Phone") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Phone, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Email, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = secondaryPhone,
onValueChange = { secondaryPhone = it },
label = { Text("Secondary Phone") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Phone, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Business Details Section
Text(
"Business Details",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
ExposedDropdownMenuBox(
expanded = expandedSpecialtyMenu,
onExpandedChange = { expandedSpecialtyMenu = it }
) {
OutlinedTextField(
value = specialty,
onValueChange = {},
readOnly = true,
label = { Text("Specialty") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedSpecialtyMenu) },
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.WorkOutline, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
ExposedDropdownMenu(
expanded = expandedSpecialtyMenu,
onDismissRequest = { expandedSpecialtyMenu = false }
) {
specialties.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
specialty = option
expandedSpecialtyMenu = false
}
)
}
}
}
OutlinedTextField(
value = licenseNumber,
onValueChange = { licenseNumber = it },
label = { Text("License Number") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Badge, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = website,
onValueChange = { website = it },
label = { Text("Website") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Language, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Address Section
Text(
"Address",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text("Street Address") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.LocationOn, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text("City") },
modifier = Modifier.weight(1f),
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = state,
onValueChange = { state = it },
label = { Text("State") },
modifier = Modifier.weight(0.5f),
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
}
OutlinedTextField(
value = zipCode,
onValueChange = { zipCode = it },
label = { Text("ZIP Code") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Notes Section
Text(
"Notes",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Private Notes") },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
maxLines = 4,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Notes, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
// Favorite toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = if (isFavorite) Color(0xFFF59E0B) else Color(0xFF9CA3AF)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Mark as Favorite", color = Color(0xFF111827))
}
Switch(
checked = isFavorite,
onCheckedChange = { isFavorite = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF3B82F6)
)
)
}
// Error messages
when (val state = if (contractorId == null) createState else updateState) {
is ApiResult.Error -> {
Text(
state.message,
color = Color(0xFFEF4444),
style = MaterialTheme.typography.bodySmall
)
}
else -> {}
}
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
if (contractorId == null) {
viewModel.createContractor(
ContractorCreateRequest(
name = name,
company = company.takeIf { it.isNotBlank() },
phone = phone.takeIf { it.isNotBlank() },
email = email.takeIf { it.isNotBlank() },
secondaryPhone = secondaryPhone.takeIf { it.isNotBlank() },
specialty = specialty.takeIf { it.isNotBlank() },
licenseNumber = licenseNumber.takeIf { it.isNotBlank() },
website = website.takeIf { it.isNotBlank() },
address = address.takeIf { it.isNotBlank() },
city = city.takeIf { it.isNotBlank() },
state = state.takeIf { it.isNotBlank() },
zipCode = zipCode.takeIf { it.isNotBlank() },
isFavorite = isFavorite,
notes = notes.takeIf { it.isNotBlank() }
)
)
} else {
viewModel.updateContractor(
contractorId,
ContractorUpdateRequest(
name = name,
company = company.takeIf { it.isNotBlank() },
phone = phone,
email = email.takeIf { it.isNotBlank() },
secondaryPhone = secondaryPhone.takeIf { it.isNotBlank() },
specialty = specialty.takeIf { it.isNotBlank() },
licenseNumber = licenseNumber.takeIf { it.isNotBlank() },
website = website.takeIf { it.isNotBlank() },
address = address.takeIf { it.isNotBlank() },
city = city.takeIf { it.isNotBlank() },
state = state.takeIf { it.isNotBlank() },
zipCode = zipCode.takeIf { it.isNotBlank() },
isFavorite = isFavorite,
notes = notes.takeIf { it.isNotBlank() }
)
)
}
}
},
enabled = name.isNotBlank() &&
createState !is ApiResult.Loading && updateState !is ApiResult.Loading,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2563EB)
)
) {
if (createState is ApiResult.Loading || updateState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(if (contractorId == null) "Add" else "Save")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color(0xFF6B7280))
}
},
containerColor = Color.White,
shape = RoundedCornerShape(16.dp)
)
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.ui.components
import androidx.compose.runtime.Composable
import com.example.casera.models.TaskCreateRequest
@Composable
fun AddNewTaskDialog(
residenceId: Int,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit
) {
AddTaskDialog(
residenceId = residenceId,
residencesResponse = null,
onDismiss = onDismiss,
onCreate = onCreate
)
}

View File

@@ -0,0 +1,23 @@
package com.example.casera.ui.components
import androidx.compose.runtime.Composable
import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskCreateRequest
@Composable
fun AddNewTaskWithResidenceDialog(
residencesResponse: MyResidencesResponse,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit,
isLoading: Boolean = false,
errorMessage: String? = null
) {
AddTaskDialog(
residenceId = null,
residencesResponse = residencesResponse,
onDismiss = onDismiss,
onCreate = onCreate,
isLoading = isLoading,
errorMessage = errorMessage
)
}

View File

@@ -0,0 +1,370 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskCategory
import com.example.casera.models.TaskCreateRequest
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskPriority
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskDialog(
residenceId: Int? = null,
residencesResponse: MyResidencesResponse? = null,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit,
isLoading: Boolean = false,
errorMessage: String? = null
) {
// Determine if we need residence selection
val needsResidenceSelection = residenceId == null
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var intervalDays by remember { mutableStateOf("") }
var dueDate by remember { mutableStateOf("") }
var estimatedCost by remember { mutableStateOf("") }
var selectedResidenceId by remember {
mutableStateOf(
residenceId ?: residencesResponse?.residences?.firstOrNull()?.id ?: 0
)
}
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
var frequency by remember { mutableStateOf(TaskFrequency(id = 0, name = "")) }
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "")) }
var showResidenceDropdown by remember { mutableStateOf(false) }
var showFrequencyDropdown by remember { mutableStateOf(false) }
var showPriorityDropdown by remember { mutableStateOf(false) }
var showCategoryDropdown by remember { mutableStateOf(false) }
var titleError by remember { mutableStateOf(false) }
var categoryError by remember { mutableStateOf(false) }
var dueDateError by remember { mutableStateOf(false) }
var residenceError by remember { mutableStateOf(false) }
// Get data from LookupsRepository
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
// Set defaults when data loads
LaunchedEffect(frequencies) {
if (frequencies.isNotEmpty()) {
frequency = frequencies.first()
}
}
LaunchedEffect(priorities) {
if (priorities.isNotEmpty()) {
priority = priorities.first()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Task") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Residence Selector (only if residenceId is null)
if (needsResidenceSelection && residencesResponse != null) {
ExposedDropdownMenuBox(
expanded = showResidenceDropdown,
onExpandedChange = { showResidenceDropdown = it }
) {
OutlinedTextField(
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
onValueChange = { },
label = { Text("Property *") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = residenceError,
supportingText = if (residenceError) {
{ Text("Property is required") }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
readOnly = true,
enabled = residencesResponse.residences.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showResidenceDropdown,
onDismissRequest = { showResidenceDropdown = false }
) {
residencesResponse.residences.forEach { residence ->
DropdownMenuItem(
text = { Text(residence.name) },
onClick = {
selectedResidenceId = residence.id
residenceError = false
showResidenceDropdown = false
}
)
}
}
}
}
// Title
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
},
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError,
supportingText = if (titleError) {
{ Text("Title is required") }
} else null,
singleLine = true
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
)
// Category
ExposedDropdownMenuBox(
expanded = showCategoryDropdown,
onExpandedChange = { showCategoryDropdown = it }
) {
OutlinedTextField(
value = categories.find { it == category }?.name ?: "",
onValueChange = { },
label = { Text("Category *") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = categoryError,
supportingText = if (categoryError) {
{ Text("Category is required") }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
readOnly = false,
enabled = categories.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false }
) {
categories.forEach { cat ->
DropdownMenuItem(
text = { Text(cat.name) },
onClick = {
category = cat
categoryError = false
showCategoryDropdown = false
}
)
}
}
}
// Frequency
ExposedDropdownMenuBox(
expanded = showFrequencyDropdown,
onExpandedChange = { showFrequencyDropdown = it }
) {
OutlinedTextField(
value = frequencies.find { it == frequency }?.displayName ?: "",
onValueChange = { },
label = { Text("Frequency") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showFrequencyDropdown,
onDismissRequest = { showFrequencyDropdown = false }
) {
frequencies.forEach { freq ->
DropdownMenuItem(
text = { Text(freq.displayName) },
onClick = {
frequency = freq
showFrequencyDropdown = false
// Clear interval days if frequency is "once"
if (freq.name == "once") {
intervalDays = ""
}
}
)
}
}
}
// Interval Days (only for recurring tasks)
if (frequency.name != "once") {
OutlinedTextField(
value = intervalDays,
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
label = { Text("Interval Days (optional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text("Override default frequency interval") },
singleLine = true
)
}
// Due Date
OutlinedTextField(
value = dueDate,
onValueChange = {
dueDate = it
dueDateError = false
},
label = { Text("Due Date (YYYY-MM-DD) *") },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError,
supportingText = if (dueDateError) {
{ Text("Due date is required (format: YYYY-MM-DD)") }
} else {
{ Text("Format: YYYY-MM-DD") }
},
singleLine = true
)
// Priority
ExposedDropdownMenuBox(
expanded = showPriorityDropdown,
onExpandedChange = { showPriorityDropdown = it }
) {
OutlinedTextField(
value = priorities.find { it.name == priority.name }?.displayName ?: "",
onValueChange = { },
label = { Text("Priority") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
enabled = priorities.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showPriorityDropdown,
onDismissRequest = { showPriorityDropdown = false }
) {
priorities.forEach { prio ->
DropdownMenuItem(
text = { Text(prio.displayName) },
onClick = {
priority = prio
showPriorityDropdown = false
}
)
}
}
}
// Estimated Cost
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") },
singleLine = true
)
// Error message display
if (errorMessage != null) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp)
)
}
}
},
confirmButton = {
Button(
onClick = {
// Validation
var hasError = false
if (needsResidenceSelection && selectedResidenceId == 0) {
residenceError = true
hasError = true
}
if (title.isBlank()) {
titleError = true
hasError = true
}
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
dueDateError = true
hasError = true
}
if (!hasError) {
onCreate(
TaskCreateRequest(
residenceId = selectedResidenceId,
title = title,
description = description.ifBlank { null },
categoryId = if (category.id > 0) category.id else null,
frequencyId = if (frequency.id > 0) frequency.id else null,
priorityId = if (priority.id > 0) priority.id else null,
statusId = null,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }?.toDoubleOrNull()
)
)
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Create Task")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
// Helper function to validate date format
private fun isValidDateFormat(date: String): Boolean {
val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
return datePattern.matches(date)
}

View File

@@ -0,0 +1,148 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.example.casera.network.ApiResult
/**
* Handles ApiResult states automatically with loading, error dialogs, and success content.
*
* Example usage:
* ```
* val state by viewModel.dataState.collectAsState()
*
* ApiResultHandler(
* state = state,
* onRetry = { viewModel.loadData() }
* ) { data ->
* // Success content using the data
* Text("Data: ${data.name}")
* }
* ```
*
* @param T The type of data in the ApiResult.Success
* @param state The current ApiResult state
* @param onRetry Callback to retry the operation when error occurs
* @param modifier Modifier for the container
* @param loadingContent Custom loading content (default: CircularProgressIndicator)
* @param errorTitle Custom error dialog title (default: "Network Error")
* @param content Content to show when state is Success
*/
@Composable
fun <T> ApiResultHandler(
state: ApiResult<T>,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
loadingContent: @Composable (() -> Unit)? = null,
errorTitle: String = "Network Error",
content: @Composable (T) -> Unit
) {
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
// Show error dialog when state changes to Error
LaunchedEffect(state) {
if (state is ApiResult.Error) {
errorMessage = state.message
showErrorDialog = true
}
}
when (state) {
is ApiResult.Idle, is ApiResult.Loading -> {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (loadingContent != null) {
loadingContent()
} else {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
// Show loading indicator while error dialog is displayed
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
Box(modifier = modifier.fillMaxSize()) {
content(state.data)
}
}
}
// Error dialog
if (showErrorDialog) {
ErrorDialog(
title = errorTitle,
message = errorMessage,
onRetry = {
showErrorDialog = false
onRetry()
},
onDismiss = {
showErrorDialog = false
}
)
}
}
/**
* Extension function to observe ApiResult state and show error dialog
* Use this for operations that don't return data to display (like create/update/delete)
*
* Example usage:
* ```
* val createState by viewModel.createState.collectAsState()
*
* createState.HandleErrors(
* onRetry = { viewModel.createItem() }
* )
*
* LaunchedEffect(createState) {
* if (createState is ApiResult.Success) {
* // Handle success
* navController.popBackStack()
* }
* }
* ```
*/
@Composable
fun <T> ApiResult<T>.HandleErrors(
onRetry: () -> Unit,
errorTitle: String = "Network Error"
) {
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
LaunchedEffect(this) {
if (this@HandleErrors is ApiResult.Error) {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((this@HandleErrors as ApiResult.Error).message)
showErrorDialog = true
}
}
if (showErrorDialog) {
ErrorDialog(
title = errorTitle,
message = errorMessage,
onRetry = {
showErrorDialog = false
onRetry()
},
onDismiss = {
showErrorDialog = false
}
)
}
}

View File

@@ -0,0 +1,298 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.network.ApiResult
import com.example.casera.platform.ImageData
import com.example.casera.platform.rememberImagePicker
import com.example.casera.platform.rememberCameraPicker
import kotlinx.datetime.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompleteTaskDialog(
taskId: Int,
taskTitle: String,
onDismiss: () -> Unit,
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit,
contractorViewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
var completedByName by remember { mutableStateOf("") }
var actualCost by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var rating by remember { mutableStateOf(3) }
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
var selectedContractorId by remember { mutableStateOf<Int?>(null) }
var selectedContractorName by remember { mutableStateOf<String?>(null) }
var showContractorDropdown by remember { mutableStateOf(false) }
val contractorsState by contractorViewModel.contractorsState.collectAsState()
// Load contractors when dialog opens
LaunchedEffect(Unit) {
contractorViewModel.loadContractors()
}
val imagePicker = rememberImagePicker { images ->
selectedImages = images
}
val cameraPicker = rememberCameraPicker { image ->
selectedImages = selectedImages + image
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Complete Task: $taskTitle") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Contractor Selection Dropdown
ExposedDropdownMenuBox(
expanded = showContractorDropdown,
onExpandedChange = { showContractorDropdown = !showContractorDropdown }
) {
OutlinedTextField(
value = selectedContractorName ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Select Contractor (optional)") },
placeholder = { Text("Choose a contractor or leave blank") },
trailingIcon = {
Icon(Icons.Default.ArrowDropDown, "Expand")
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
colors = OutlinedTextFieldDefaults.colors()
)
ExposedDropdownMenu(
expanded = showContractorDropdown,
onDismissRequest = { showContractorDropdown = false }
) {
// "None" option to clear selection
DropdownMenuItem(
text = { Text("None (manual entry)") },
onClick = {
selectedContractorId = null
selectedContractorName = null
showContractorDropdown = false
}
)
// Contractor list
when (val state = contractorsState) {
is ApiResult.Success -> {
state.data.forEach { contractor ->
DropdownMenuItem(
text = {
Column {
Text(contractor.name)
contractor.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
onClick = {
selectedContractorId = contractor.id
selectedContractorName = if (contractor.company != null) {
"${contractor.name} (${contractor.company})"
} else {
contractor.name
}
showContractorDropdown = false
}
)
}
}
is ApiResult.Loading -> {
DropdownMenuItem(
text = { Text("Loading contractors...") },
onClick = {},
enabled = false
)
}
is ApiResult.Error -> {
DropdownMenuItem(
text = { Text("Error loading contractors") },
onClick = {},
enabled = false
)
}
else -> {}
}
}
}
OutlinedTextField(
value = completedByName,
onValueChange = { completedByName = it },
label = { Text("Completed By Name (optional)") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Enter name if not using contractor above") },
enabled = selectedContractorId == null
)
OutlinedTextField(
value = actualCost,
onValueChange = { actualCost = it },
label = { Text("Actual Cost (optional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") }
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Column {
Text("Rating: $rating out of 5")
Slider(
value = rating.toFloat(),
onValueChange = { rating = it.toInt() },
valueRange = 1f..5f,
steps = 3,
modifier = Modifier.fillMaxWidth()
)
}
// Image upload section
Column {
Text(
text = "Add Images",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { cameraPicker() },
modifier = Modifier.weight(1f)
) {
Text("Take Photo")
}
OutlinedButton(
onClick = { imagePicker() },
modifier = Modifier.weight(1f)
) {
Text("Choose from Library")
}
}
// Display selected images
if (selectedImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${selectedImages.size} image(s) selected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
selectedImages.forEach { image ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(
text = image.fileName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
selectedImages = selectedImages.filter { it != image }
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove image",
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}
},
confirmButton = {
Button(
onClick = {
// Get current date in ISO format
val currentDate = getCurrentDateTime()
// Build notes with contractor info if selected
val notesWithContractor = buildString {
if (selectedContractorName != null) {
append("Contractor: $selectedContractorName\n")
}
if (completedByName.isNotBlank()) {
append("Completed by: $completedByName\n")
}
if (notes.isNotBlank()) {
append(notes)
}
}.ifBlank { null }
onComplete(
TaskCompletionCreateRequest(
taskId = taskId,
completedAt = currentDate,
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null // Images uploaded separately and URLs added by handler
),
selectedImages
)
}
) {
Text("Complete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
// Helper function to get current date/time in ISO format
private fun getCurrentDateTime(): String {
return kotlinx.datetime.LocalDate.toString()
}

View File

@@ -0,0 +1,65 @@
package com.example.casera.ui.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Reusable error dialog component that shows network errors with retry/cancel options
*
* @param title Dialog title (default: "Network Error")
* @param message Error message to display
* @param onRetry Callback when user clicks Retry button
* @param onDismiss Callback when user clicks Cancel or dismisses dialog
* @param retryButtonText Text for retry button (default: "Try Again")
* @param dismissButtonText Text for dismiss button (default: "Cancel")
*/
@Composable
fun ErrorDialog(
title: String = "Network Error",
message: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
retryButtonText: String = "Try Again",
dismissButtonText: String = "Cancel"
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Button(
onClick = {
onDismiss()
onRetry()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(retryButtonText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissButtonText)
}
}
)
}

View File

@@ -0,0 +1,134 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.launch
@Composable
fun JoinResidenceDialog(
onDismiss: () -> Unit,
onJoined: () -> Unit = {}
) {
var shareCode by remember { mutableStateOf(TextFieldValue("")) }
var isJoining by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val residenceApi = remember { ResidenceApi() }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Join Residence")
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Enter the 6-character share code to join a residence",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = shareCode,
onValueChange = {
if (it.text.length <= 6) {
shareCode = it.copy(text = it.text.uppercase())
error = null
}
},
label = { Text("Share Code") },
placeholder = { Text("ABC123") },
singleLine = true,
enabled = !isJoining,
isError = error != null,
supportingText = {
if (error != null) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
modifier = Modifier.fillMaxWidth()
)
if (isJoining) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
},
confirmButton = {
Button(
onClick = {
if (shareCode.text.length == 6) {
scope.launch {
isJoining = true
error = null
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.joinWithCode(token, shareCode.text)) {
is ApiResult.Success -> {
isJoining = false
onJoined()
onDismiss()
}
is ApiResult.Error -> {
error = result.message
isJoining = false
}
else -> {
isJoining = false
}
}
} else {
error = "Not authenticated"
isJoining = false
}
}
} else {
error = "Share code must be 6 characters"
}
},
enabled = !isJoining && shareCode.text.length == 6
) {
Text("Join")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isJoining
) {
Text("Cancel")
}
}
)
}

View File

@@ -0,0 +1,279 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.casera.models.ResidenceUser
import com.example.casera.models.ResidenceShareCode
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.launch
@Composable
fun ManageUsersDialog(
residenceId: Int,
residenceName: String,
isPrimaryOwner: Boolean,
onDismiss: () -> Unit,
onUserRemoved: () -> Unit = {}
) {
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
var ownerId by remember { mutableStateOf<Int?>(null) }
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var isGeneratingCode by remember { mutableStateOf(false) }
val residenceApi = remember { ResidenceApi() }
val scope = rememberCoroutineScope()
// Load users
LaunchedEffect(residenceId) {
// Clear share code on open so it's always blank
shareCode = null
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
is ApiResult.Success -> {
users = result.data.users
ownerId = result.data.owner.id
isLoading = false
}
is ApiResult.Error -> {
error = result.message
isLoading = false
}
else -> {}
}
// Don't auto-load share code - user must generate it explicitly
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Manage Users")
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (error != null) {
Text(
text = error ?: "Unknown error",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
} else {
// Share code section (primary owner only)
if (isPrimaryOwner) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Share Code",
style = MaterialTheme.typography.titleSmall
)
if (shareCode != null) {
Text(
text = shareCode!!.code,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(
text = "No active code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
FilledTonalButton(
onClick = {
scope.launch {
isGeneratingCode = true
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.generateShareCode(token, residenceId)) {
is ApiResult.Success -> {
shareCode = result.data.shareCode
}
is ApiResult.Error -> {
error = result.message
}
else -> {}
}
}
isGeneratingCode = false
}
},
enabled = !isGeneratingCode
) {
Icon(Icons.Default.Share, "Generate", modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(if (shareCode != null) "New Code" else "Generate")
}
}
if (shareCode != null) {
Text(
text = "Share this code with others to give them access to $residenceName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
// Users list
Text(
text = "Users (${users.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyColumn(
modifier = Modifier.fillMaxWidth().height(300.dp)
) {
items(users) { user ->
UserListItem(
user = user,
isOwner = user.id == ownerId,
isPrimaryOwner = isPrimaryOwner,
onRemove = {
scope.launch {
val token = TokenStorage.getToken()
if (token != null) {
when (residenceApi.removeUser(token, residenceId, user.id)) {
is ApiResult.Success -> {
users = users.filter { it.id != user.id }
onUserRemoved()
}
is ApiResult.Error -> {
// Show error
}
else -> {}
}
}
}
}
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@Composable
private fun UserListItem(
user: ResidenceUser,
isOwner: Boolean,
isPrimaryOwner: Boolean,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = user.username,
style = MaterialTheme.typography.bodyLarge
)
if (isOwner) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "Owner",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
if (!user.email.isNullOrEmpty()) {
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val fullName = listOfNotNull(user.firstName, user.lastName)
.filter { it.isNotEmpty() }
.joinToString(" ")
if (fullName.isNotEmpty()) {
Text(
text = fullName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (isPrimaryOwner && !isOwner) {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove user",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
package com.example.casera.ui.components.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun AuthHeader(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun AuthHeaderPreview() {
MaterialTheme {
Surface {
AuthHeader(
icon = Icons.Default.Home,
title = "myCrib",
subtitle = "Manage your properties with ease",
modifier = Modifier.padding(32.dp)
)
}
}
}

View File

@@ -0,0 +1,36 @@
package com.example.casera.ui.components.auth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RequirementItem(text: String, satisfied: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (satisfied) Icons.Default.CheckCircle else Icons.Default.Circle,
contentDescription = null,
tint = if (satisfied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = if (satisfied) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,79 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.backgroundSecondary
/**
* CompactCard - Smaller card with reduced padding
*
* Features:
* - Standard 12dp corner radius
* - Compact padding (12dp default)
* - Subtle shadow
* - Uses theme background secondary color
*
* Usage:
* ```
* CompactCard {
* Text("Compact content")
* }
* ```
*/
@Composable
fun CompactCard(
modifier: Modifier = Modifier,
contentPadding: Dp = AppSpacing.md,
backgroundColor: Color? = null,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = if (backgroundColor != null) {
CardDefaults.cardColors(containerColor = backgroundColor)
} else {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.backgroundSecondary
)
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp,
pressedElevation = 2.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
} else {
Card(
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun ErrorCard(
message: String,
modifier: Modifier = Modifier
) {
if (message.isNotEmpty()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Preview
@Composable
fun ErrorCardPreview() {
MaterialTheme {
ErrorCard(
message = "Invalid username or password. Please try again.",
modifier = Modifier.padding(16.dp)
)
}
}

View File

@@ -0,0 +1,69 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun InfoCard(
icon: ImageVector,
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
content()
}
}
}
@Preview
@Composable
fun InfoCardPreview() {
MaterialTheme {
InfoCard(
icon = Icons.Default.Info,
title = "Sample Information"
) {
Text("This is sample content")
Text("Line 2 of content")
Text("Line 3 of content")
}
}
}

View File

@@ -0,0 +1,79 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.backgroundSecondary
/**
* StandardCard - Consistent card component matching iOS design
*
* Features:
* - Standard 12dp corner radius
* - Consistent padding (16dp default)
* - Subtle shadow for elevation
* - Uses theme background secondary color
*
* Usage:
* ```
* StandardCard {
* Text("Card content")
* }
* ```
*/
@Composable
fun StandardCard(
modifier: Modifier = Modifier,
contentPadding: Dp = AppSpacing.lg,
backgroundColor: Color? = null,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = if (backgroundColor != null) {
CardDefaults.cardColors(containerColor = backgroundColor)
} else {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.backgroundSecondary
)
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp,
pressedElevation = 4.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
} else {
Card(
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
}
}

View File

@@ -0,0 +1,125 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppSpacing
/**
* StandardEmptyState - Consistent empty state component
* Matches iOS empty state pattern
*
* Features:
* - Icon + title + subtitle pattern
* - Optional action button
* - Centered layout
* - Consistent styling
*
* Usage:
* ```
* StandardEmptyState(
* icon = Icons.Default.FolderOpen,
* title = "No Tasks",
* subtitle = "Get started by adding your first task",
* actionLabel = "Add Task",
* onAction = { /* ... */ }
* )
* ```
*/
@Composable
fun StandardEmptyState(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onAction: (() -> Unit)? = null
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
) {
// Icon
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
// Text content
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 280.dp)
)
}
// Action button
if (actionLabel != null && onAction != null) {
Button(
onClick = onAction,
modifier = Modifier.padding(top = AppSpacing.sm)
) {
Text(actionLabel)
}
}
}
}
}
/**
* Compact version of empty state for smaller spaces
*/
@Composable
fun CompactEmptyState(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}

View File

@@ -0,0 +1,60 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun StatItem(
icon: ImageVector,
value: String,
label: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
@Preview
@Composable
fun StatItemPreview() {
MaterialTheme {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(16.dp)
) {
StatItem(
icon = Icons.Default.Home,
value = "5",
label = "Properties"
)
}
}
}

View File

@@ -0,0 +1,203 @@
package com.example.casera.ui.components.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.ui.theme.*
/**
* ThemePickerDialog - Shows all available themes in a grid
* Matches iOS theme picker functionality
*
* Features:
* - Grid layout with 2 columns
* - Shows theme preview colors
* - Current theme highlighted with checkmark
* - Theme name and description
*
* Usage:
* ```
* if (showThemePicker) {
* ThemePickerDialog(
* currentTheme = ThemeManager.currentTheme,
* onThemeSelected = { theme ->
* ThemeManager.setTheme(theme)
* showThemePicker = false
* },
* onDismiss = { showThemePicker = false }
* )
* }
* ```
*/
@Composable
fun ThemePickerDialog(
currentTheme: ThemeColors,
onThemeSelected: (ThemeColors) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
),
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg)
) {
Column(
modifier = Modifier.padding(AppSpacing.xl)
) {
// Header
Text(
text = "Choose Theme",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(bottom = AppSpacing.lg)
)
// Theme Grid
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md),
modifier = Modifier.heightIn(max = 400.dp)
) {
items(ThemeManager.getAllThemes()) { theme ->
ThemeCard(
theme = theme,
isSelected = theme.id == currentTheme.id,
onClick = { onThemeSelected(theme) }
)
}
}
// Close button
Spacer(modifier = Modifier.height(AppSpacing.lg))
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text("Close")
}
}
}
}
}
/**
* Individual theme card in the picker
*/
@Composable
private fun ThemeCard(
theme: ThemeColors,
isSelected: Boolean,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(AppRadius.md)
)
} else {
Modifier
}
),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.backgroundSecondary
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Color preview circles
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
modifier = Modifier.padding(bottom = AppSpacing.sm)
) {
// Preview with light mode colors
ColorCircle(theme.lightPrimary)
ColorCircle(theme.lightSecondary)
ColorCircle(theme.lightAccent)
}
// Theme name
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = theme.displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
}
// Theme description
Text(
text = theme.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}
/**
* Small colored circle for theme preview
*/
@Composable
private fun ColorCircle(color: Color) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(color)
.border(
width = 1.dp,
color = Color.Black.copy(alpha = 0.1f),
shape = CircleShape
)
)
}

View File

@@ -0,0 +1,243 @@
package com.example.casera.ui.components.documents
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.casera.models.Document
import com.example.casera.models.DocumentCategory
import com.example.casera.models.DocumentType
@Composable
fun DocumentCard(document: Document, isWarrantyCard: Boolean = false, onClick: () -> Unit) {
if (isWarrantyCard) {
WarrantyCardContent(document = document, onClick = onClick)
} else {
RegularDocumentCardContent(document = document, onClick = onClick)
}
}
@Composable
private fun WarrantyCardContent(document: Document, onClick: () -> Unit) {
val daysUntilExpiration = document.daysUntilExpiration ?: 0
val statusColor = when {
!document.isActive -> Color.Gray
daysUntilExpiration < 0 -> Color.Red
daysUntilExpiration < 30 -> Color(0xFFF59E0B)
daysUntilExpiration < 90 -> Color(0xFFFBBF24)
else -> Color(0xFF10B981)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
document.itemName?.let { itemName ->
Text(
itemName,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Box(
modifier = Modifier
.background(statusColor.copy(alpha = 0.2f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
when {
!document.isActive -> "Inactive"
daysUntilExpiration < 0 -> "Expired"
daysUntilExpiration < 30 -> "Expiring soon"
else -> "Active"
},
style = MaterialTheme.typography.labelSmall,
color = statusColor,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("Provider", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.provider ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
Column(horizontalAlignment = Alignment.End) {
Text("Expires", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.endDate ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
if (document.isActive && daysUntilExpiration >= 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"$daysUntilExpiration days remaining",
style = MaterialTheme.typography.labelMedium,
color = statusColor
)
}
document.category?.let { category ->
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.background(Color(0xFFE5E7EB), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
DocumentCategory.fromValue(category).displayName,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF374151)
)
}
}
}
}
}
@Composable
private fun RegularDocumentCardContent(document: Document, onClick: () -> Unit) {
val typeColor = when (document.documentType) {
"warranty" -> Color(0xFF3B82F6)
"manual" -> Color(0xFF8B5CF6)
"receipt" -> Color(0xFF10B981)
"inspection" -> Color(0xFFF59E0B)
else -> Color(0xFF6B7280)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Document icon
Box(
modifier = Modifier
.size(56.dp)
.background(typeColor.copy(alpha = 0.1f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(
when (document.documentType) {
"photo" -> Icons.Default.Image
"warranty", "insurance" -> Icons.Default.VerifiedUser
"manual" -> Icons.Default.MenuBook
"receipt" -> Icons.Default.Receipt
else -> Icons.Default.Description
},
contentDescription = null,
tint = typeColor,
modifier = Modifier.size(32.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
if (document.description?.isNotBlank() == true) {
Text(
document.description,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(typeColor.copy(alpha = 0.2f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
DocumentType.fromValue(document.documentType).displayName,
style = MaterialTheme.typography.labelSmall,
color = typeColor
)
}
document.fileSize?.let { size ->
Text(
formatFileSize(size),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = Color.Gray
)
}
}
}
fun formatFileSize(bytes: Int): String {
var size = bytes.toDouble()
val units = listOf("B", "KB", "MB", "GB")
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
// Round to 1 decimal place
val rounded = (size * 10).toInt() / 10.0
return "$rounded ${units[unitIndex]}"
}

View File

@@ -0,0 +1,42 @@
package com.example.casera.ui.components.documents
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(icon: ImageVector, message: String) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.titleMedium, color = Color.Gray)
}
}
@Composable
fun ErrorState(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Red)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}

View File

@@ -0,0 +1,97 @@
package com.example.casera.ui.components.documents
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.casera.models.Document
import com.example.casera.network.ApiResult
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentsTabContent(
state: ApiResult<List<Document>>,
isWarrantyTab: Boolean,
onDocumentClick: (Int) -> Unit,
onRetry: () -> Unit,
onNavigateBack: () -> Unit = {}
) {
val shouldShowUpgradePrompt = SubscriptionHelper.shouldShowUpgradePromptForDocuments().allowed
var isRefreshing by remember { mutableStateOf(false) }
// Handle refresh state
LaunchedEffect(state) {
if (state !is ApiResult.Loading) {
isRefreshing = false
}
}
when (state) {
is ApiResult.Loading -> {
if (!isRefreshing) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
is ApiResult.Success -> {
val documents = state.data
if (documents.isEmpty()) {
if (shouldShowUpgradePrompt) {
// Free tier users see upgrade prompt
UpgradeFeatureScreen(
triggerKey = "view_documents",
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see empty state
EmptyState(
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
message = if (isWarrantyTab) "No warranties found" else "No documents found"
)
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
onRetry()
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(documents) { document ->
DocumentCard(
document = document,
isWarrantyCard = isWarrantyTab,
onClick = { document.id?.let { onDocumentClick(it) } }
)
}
}
}
}
}
is ApiResult.Error -> {
ErrorState(message = state.message, onRetry = onRetry)
}
is ApiResult.Idle -> {}
}
}

View File

@@ -0,0 +1,68 @@
package com.example.casera.ui.components.forms
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.casera.ui.theme.AppSpacing
/**
* FormSection - Groups related form fields with optional header/footer
* Matches iOS Section pattern
*
* Features:
* - Consistent spacing between fields
* - Optional header and footer text
* - Automatic vertical spacing
*
* Usage:
* ```
* FormSection(
* header = "Personal Information",
* footer = "This information is private"
* ) {
* FormTextField(...)
* FormTextField(...)
* }
* ```
*/
@Composable
fun FormSection(
modifier: Modifier = Modifier,
header: String? = null,
footer: String? = null,
content: @Composable ColumnScope.() -> Unit
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Header
if (header != null) {
Text(
text = header,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = AppSpacing.xs)
)
}
// Content
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
content()
}
// Footer
if (footer != null) {
Text(
text = footer,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}

View File

@@ -0,0 +1,96 @@
package com.example.casera.ui.components.forms
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppSpacing
/**
* FormTextField - Standardized text field for forms
*
* Features:
* - Consistent styling across app
* - Optional leading icon
* - Error state support
* - Helper text support
* - Outlined style matching iOS design
*
* Usage:
* ```
* FormTextField(
* value = name,
* onValueChange = { name = it },
* label = "Name",
* error = if (nameError) "Name is required" else null,
* leadingIcon = Icons.Default.Person
* )
* ```
*/
@Composable
fun FormTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
leadingIcon: ImageVector? = null,
trailingIcon: @Composable (() -> Unit)? = null,
error: String? = null,
helperText: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
leadingIcon = leadingIcon?.let {
{ Icon(it, contentDescription = null) }
},
trailingIcon = trailingIcon,
isError = error != null,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)
// Error or helper text
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = AppSpacing.lg, top = AppSpacing.xs)
)
} else if (helperText != null) {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = AppSpacing.lg, top = AppSpacing.xs)
)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.example.casera.ui.components.residence
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SquareFoot
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun DetailRow(
icon: ImageVector,
label: String,
value: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun DetailRowPreview() {
MaterialTheme {
DetailRow(
icon = Icons.Default.SquareFoot,
label = "Square Footage",
value = "1800 sq ft"
)
}
}

View File

@@ -0,0 +1,54 @@
package com.example.casera.ui.components.residence
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bed
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun PropertyDetailItem(
icon: ImageVector,
value: String,
label: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun PropertyDetailItemPreview() {
MaterialTheme {
PropertyDetailItem(
icon = Icons.Default.Bed,
value = "3",
label = "Bedrooms"
)
}
}

View File

@@ -0,0 +1,58 @@
package com.example.casera.ui.components.residence
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun TaskStatChip(
icon: ImageVector,
value: String,
label: String,
color: Color
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = color
)
Text(
text = "$value",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun TaskStatChipPreview() {
MaterialTheme {
TaskStatChip(
icon = Icons.Default.CheckCircle,
value = "12",
label = "Completed",
color = MaterialTheme.colorScheme.tertiary
)
}
}

View File

@@ -0,0 +1,402 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.casera.models.TaskCompletionResponse
import com.example.casera.models.TaskCompletion
import com.example.casera.network.ApiResult
import com.example.casera.network.APILayer
import kotlinx.coroutines.launch
/**
* Bottom sheet dialog that displays all completions for a task
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompletionHistorySheet(
taskId: Int,
taskTitle: String,
onDismiss: () -> Unit
) {
val scope = rememberCoroutineScope()
var completions by remember { mutableStateOf<List<TaskCompletionResponse>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Load completions when the sheet opens
LaunchedEffect(taskId) {
isLoading = true
errorMessage = null
when (val result = APILayer.getTaskCompletions(taskId)) {
is ApiResult.Success -> {
completions = result.data
isLoading = false
}
is ApiResult.Error -> {
errorMessage = result.message
isLoading = false
}
else -> {
isLoading = false
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
containerColor = MaterialTheme.colorScheme.surface,
dragHandle = { BottomSheetDefaults.DragHandle() }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
) {
// Header
Text(
text = "Completion History",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// Task title
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Task,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = taskTitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
if (!isLoading && errorMessage == null) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "${completions.size} ${if (completions.size == 1) "completion" else "completions"}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Content
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading completions...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
errorMessage != null -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Failed to load completions",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = errorMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
scope.launch {
isLoading = true
errorMessage = null
when (val result = APILayer.getTaskCompletions(taskId)) {
is ApiResult.Success -> {
completions = result.data
isLoading = false
}
is ApiResult.Error -> {
errorMessage = result.message
isLoading = false
}
else -> {
isLoading = false
}
}
}
}
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
}
}
completions.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.CheckCircleOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No Completions Yet",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "This task has not been completed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.heightIn(max = 400.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(completions) { completion ->
CompletionHistoryCard(completion = completion)
}
}
}
}
}
}
}
/**
* Card displaying a single completion in the history sheet
*/
@Composable
private fun CompletionHistoryCard(completion: TaskCompletionResponse) {
var showPhotoDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header with date and completed by
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column {
Text(
text = formatCompletionDate(completion.completedAt),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
completion.completedBy?.let { user ->
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Completed by ${user.displayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Cost
completion.actualCost?.let { cost ->
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.AttachMoney,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$$cost",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Notes
if (completion.notes.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Notes",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = completion.notes,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// Rating
completion.rating?.let { rating ->
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$rating / 5",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Photo button
if (completion.images.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { showPhotoDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (completion.images.size == 1) "View Photo" else "View Photos (${completion.images.size})",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
// Photo viewer dialog
if (showPhotoDialog && completion.images.isNotEmpty()) {
PhotoViewerDialog(
images = completion.images,
onDismiss = { showPhotoDialog = false }
)
}
}
private fun formatCompletionDate(dateString: String): String {
// Try to parse and format the date
return try {
val parts = dateString.split("T")
if (parts.isNotEmpty()) {
val dateParts = parts[0].split("-")
if (dateParts.size == 3) {
val year = dateParts[0]
val month = dateParts[1].toIntOrNull() ?: 1
val day = dateParts[2].toIntOrNull() ?: 1
val monthNames = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthNames.getOrElse(month) { "Jan" }} $day, $year"
} else {
dateString
}
} else {
dateString
}
} catch (e: Exception) {
dateString
}
}

View File

@@ -0,0 +1,234 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.example.casera.models.TaskCompletionImage
import com.example.casera.network.ApiClient
@Composable
fun PhotoViewerDialog(
images: List<TaskCompletionImage>,
onDismiss: () -> Unit
) {
var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) }
val baseUrl = ApiClient.getMediaBaseUrl()
Dialog(
onDismissRequest = {
if (selectedImage != null) {
selectedImage = null
} else {
onDismiss()
}
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (selectedImage != null) "Photo" else "Completion Photos",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = {
if (selectedImage != null) {
selectedImage = null
} else {
onDismiss()
}
}) {
Icon(
Icons.Default.Close,
contentDescription = "Close"
)
}
}
HorizontalDivider()
// Content
if (selectedImage != null) {
// Single image view
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
SubcomposeAsyncImage(
model = baseUrl + selectedImage!!.image,
contentDescription = selectedImage!!.caption ?: "Task completion photo",
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentScale = ContentScale.Fit
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AsyncImagePainter.State.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error loading image",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Failed to load image",
color = MaterialTheme.colorScheme.error
)
Text(
selectedImage!!.image,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
selectedImage!!.caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = caption,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
} else {
// Grid view
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(images) { image ->
Card(
onClick = { selectedImage = image },
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
SubcomposeAsyncImage(
model = baseUrl + image.image,
contentDescription = image.caption ?: "Task completion photo",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
}
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
image.caption?.let { caption ->
Text(
text = caption,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun SimpleTaskListItem(
title: String,
description: String?,
priority: String?,
status: String?,
dueDate: String?,
isOverdue: Boolean = false,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Priority badge
Surface(
color = when (priority?.lowercase()) {
"urgent" -> MaterialTheme.colorScheme.error
"high" -> MaterialTheme.colorScheme.errorContainer
"medium" -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
},
shape = MaterialTheme.shapes.small
) {
Text(
text = priority?.uppercase() ?: "LOW",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Status: ${status?.replaceFirstChar { it.uppercase() } ?: "Unknown"}",
style = MaterialTheme.typography.bodySmall
)
if (dueDate != null) {
Text(
text = "Due: $dueDate",
style = MaterialTheme.typography.bodySmall,
color = if (isOverdue)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Preview
@Composable
fun SimpleTaskListItemPreview() {
MaterialTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SimpleTaskListItem(
title = "Fix leaky faucet",
description = "Kitchen sink is dripping",
priority = "high",
status = "pending",
dueDate = "2024-12-20",
isOverdue = false
)
SimpleTaskListItem(
title = "Paint living room",
description = null,
priority = "medium",
status = "in_progress",
dueDate = "2024-12-15",
isOverdue = true
)
}
}
}

View File

@@ -0,0 +1,239 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.TaskViewModel
// MARK: - Edit Task Button
@Composable
fun EditTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = {
// Edit navigates to edit screen - handled by parent
onCompletion()
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Edit", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Cancel Task Button
@Composable
fun CancelTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
OutlinedButton(
onClick = {
viewModel.cancelTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to cancel task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = "Cancel",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Cancel", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Uncancel (Restore) Task Button
@Composable
fun UncancelTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
Button(
onClick = {
viewModel.uncancelTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to restore task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.Undo,
contentDescription = "Restore",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Restore", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Mark In Progress Button
@Composable
fun MarkInProgressButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
OutlinedButton(
onClick = {
viewModel.markInProgress(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to mark task in progress")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.tertiary
)
) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = "Mark In Progress",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("In Progress", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Complete Task Button
@Composable
fun CompleteTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = {
// Complete shows dialog - handled by parent
onCompletion()
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Complete",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Complete", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Archive Task Button
@Composable
fun ArchiveTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
OutlinedButton(
onClick = {
viewModel.archiveTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to archive task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.outline
)
) {
Icon(
imageVector = Icons.Default.Archive,
contentDescription = "Archive",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Archive", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Unarchive Task Button
@Composable
fun UnarchiveTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
Button(
onClick = {
viewModel.unarchiveTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to unarchive task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
imageVector = Icons.Default.Unarchive,
contentDescription = "Unarchive",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Unarchive", style = MaterialTheme.typography.labelLarge)
}
}

View File

@@ -0,0 +1,619 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.casera.models.TaskDetail
import com.example.casera.models.TaskCategory
import com.example.casera.models.TaskPriority
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskStatus
import com.example.casera.models.TaskCompletion
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun TaskCard(
task: TaskDetail,
buttonTypes: List<String> = emptyList(),
onCompleteClick: (() -> Unit)?,
onEditClick: (() -> Unit)?,
onCancelClick: (() -> Unit)?,
onUncancelClick: (() -> Unit)?,
onMarkInProgressClick: (() -> Unit)? = null,
onArchiveClick: (() -> Unit)? = null,
onUnarchiveClick: (() -> Unit)? = null,
onCompletionHistoryClick: (() -> Unit)? = null
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
// Pill-style category badge
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = (task.category?.name ?: "").uppercase(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Priority badge with semantic colors
val priorityColor = when (task.priority?.name?.lowercase()) {
"urgent", "high" -> MaterialTheme.colorScheme.error
"medium" -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.secondary
}
Row(
modifier = Modifier
.background(
priorityColor.copy(alpha = 0.15f),
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(priorityColor)
)
Text(
text = (task.priority?.name ?: "").uppercase(),
style = MaterialTheme.typography.labelSmall,
color = priorityColor
)
}
// Status badge with semantic colors
if (task.status != null) {
val statusColor = when (task.status.name.lowercase()) {
"completed" -> MaterialTheme.colorScheme.secondary
"in_progress" -> MaterialTheme.colorScheme.tertiary
"pending" -> MaterialTheme.colorScheme.tertiary
"cancelled" -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = task.status.name.replace("_", " ").uppercase(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = statusColor
)
}
}
}
}
if (task.description != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = task.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
// Metadata pills
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Date pill
Row(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.CalendarToday,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = task.nextScheduledDate ?: task.dueDate ?: "N/A",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Cost pill
task.estimatedCost?.let {
Row(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.AttachMoney,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "$$it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Actions row with completion count button and actions menu
if (buttonTypes.isNotEmpty() || task.completionCount > 0) {
var showActionsMenu by remember { mutableStateOf(false) }
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Actions dropdown menu based on buttonTypes array
if (buttonTypes.isNotEmpty()) {
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = { showActionsMenu = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Actions",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
DropdownMenu(
expanded = showActionsMenu,
onDismissRequest = { showActionsMenu = false }
) {
// Primary actions
buttonTypes.filter { isPrimaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onMarkInProgressClick = onMarkInProgressClick,
onCompleteClick = onCompleteClick,
onEditClick = onEditClick,
onUncancelClick = onUncancelClick,
onUnarchiveClick = onUnarchiveClick,
onDismiss = { showActionsMenu = false }
)
}
// Secondary actions
if (buttonTypes.any { isSecondaryAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isSecondaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onArchiveClick = onArchiveClick,
onDismiss = { showActionsMenu = false }
)
}
}
// Destructive actions
if (buttonTypes.any { isDestructiveAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isDestructiveAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onCancelClick = onCancelClick,
onDismiss = { showActionsMenu = false }
)
}
}
}
}
}
// Completion count button - shows when count > 0
if (task.completionCount > 0) {
Button(
onClick = { onCompletionHistoryClick?.invoke() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "${task.completionCount}",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
// Helper functions for action classification
private fun isPrimaryAction(buttonType: String): Boolean {
return buttonType in listOf("mark_in_progress", "complete", "edit", "uncancel", "unarchive")
}
private fun isSecondaryAction(buttonType: String): Boolean {
return buttonType == "archive"
}
private fun isDestructiveAction(buttonType: String): Boolean {
return buttonType == "cancel"
}
@Composable
private fun getActionMenuItem(
buttonType: String,
task: TaskDetail,
onMarkInProgressClick: (() -> Unit)? = null,
onCompleteClick: (() -> Unit)? = null,
onEditClick: (() -> Unit)? = null,
onCancelClick: (() -> Unit)? = null,
onUncancelClick: (() -> Unit)? = null,
onArchiveClick: (() -> Unit)? = null,
onUnarchiveClick: (() -> Unit)? = null,
onDismiss: () -> Unit
) {
when (buttonType) {
"mark_in_progress" -> {
onMarkInProgressClick?.let {
DropdownMenuItem(
text = { Text("Mark In Progress") },
leadingIcon = {
Icon(Icons.Default.PlayArrow, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"complete" -> {
onCompleteClick?.let {
DropdownMenuItem(
text = { Text("Complete Task") },
leadingIcon = {
Icon(Icons.Default.CheckCircle, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"edit" -> {
onEditClick?.let {
DropdownMenuItem(
text = { Text("Edit Task") },
leadingIcon = {
Icon(Icons.Default.Edit, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"cancel" -> {
onCancelClick?.let {
DropdownMenuItem(
text = { Text("Cancel Task") },
leadingIcon = {
Icon(
Icons.Default.Cancel,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
onClick = {
it()
onDismiss()
}
)
}
}
"uncancel" -> {
onUncancelClick?.let {
DropdownMenuItem(
text = { Text("Restore Task") },
leadingIcon = {
Icon(Icons.Default.Undo, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"archive" -> {
onArchiveClick?.let {
DropdownMenuItem(
text = { Text("Archive Task") },
leadingIcon = {
Icon(Icons.Default.Archive, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"unarchive" -> {
onUnarchiveClick?.let {
DropdownMenuItem(
text = { Text("Unarchive Task") },
leadingIcon = {
Icon(Icons.Default.Unarchive, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
}
}
@Composable
fun CompletionCard(completion: TaskCompletion) {
var showPhotoDialog by remember { mutableStateOf(false) }
val hasImages = !completion.images.isNullOrEmpty()
println("CompletionCard: hasImages = $hasImages, images count = ${completion.images?.size ?: 0}")
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = completion.completionDate.split("T")[0],
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
completion.rating?.let { rating ->
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "$rating",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
// Display contractor or manual entry
completion.contractorDetails?.let { contractor ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
text = "By: ${contractor.name}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
contractor.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} ?: completion.completedByName?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "By: $it",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
completion.actualCost?.let {
Text(
text = "Cost: $$it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
)
}
completion.notes?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Show button to view photos if images exist
if (hasImages) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
println("View Photos button clicked!")
showPhotoDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "View Photos (${completion.images?.size ?: 0})",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
// Photo viewer dialog
if (showPhotoDialog && hasImages) {
println("Showing PhotoViewerDialog with ${completion.images?.size} images")
PhotoViewerDialog(
images = completion.images!!,
onDismiss = {
println("PhotoViewerDialog dismissed")
showPhotoDialog = false
}
)
}
}
@Preview
@Composable
fun TaskCardPreview() {
MaterialTheme {
TaskCard(
task = TaskDetail(
id = 1,
residenceId = 1,
createdById = 1,
title = "Clean Gutters",
description = "Remove all debris from gutters and downspouts",
category = TaskCategory(id = 1, name = "maintenance"),
priority = TaskPriority(id = 2, name = "medium"),
frequency = TaskFrequency(
id = 1, name = "monthly", days = 30
),
status = TaskStatus(id = 1, name = "pending"),
dueDate = "2024-12-15",
estimatedCost = 150.00,
createdAt = "2024-01-01T00:00:00Z",
updatedAt = "2024-01-01T00:00:00Z",
completions = emptyList()
),
onCompleteClick = {},
onEditClick = {},
onCancelClick = {},
onUncancelClick = null
)
}
}

View File

@@ -0,0 +1,468 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.casera.models.TaskColumn
import com.example.casera.models.TaskDetail
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TaskKanbanView(
upcomingTasks: List<TaskDetail>,
inProgressTasks: List<TaskDetail>,
doneTasks: List<TaskDetail>,
archivedTasks: List<TaskDetail>,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?
) {
val pagerState = rememberPagerState(pageCount = { 4 })
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
pageSpacing = 16.dp,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp)
) { page ->
when (page) {
0 -> TaskColumn(
title = "Upcoming",
icon = Icons.Default.CalendarToday,
color = MaterialTheme.colorScheme.primary,
count = upcomingTasks.size,
tasks = upcomingTasks,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = onMarkInProgress,
onArchiveTask = onArchiveTask,
onUnarchiveTask = null
)
1 -> TaskColumn(
title = "In Progress",
icon = Icons.Default.PlayCircle,
color = MaterialTheme.colorScheme.tertiary,
count = inProgressTasks.size,
tasks = inProgressTasks,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = null,
onArchiveTask = onArchiveTask,
onUnarchiveTask = null
)
2 -> TaskColumn(
title = "Done",
icon = Icons.Default.CheckCircle,
color = MaterialTheme.colorScheme.secondary,
count = doneTasks.size,
tasks = doneTasks,
onCompleteTask = null,
onEditTask = onEditTask,
onCancelTask = null,
onUncancelTask = null,
onMarkInProgress = null,
onArchiveTask = onArchiveTask,
onUnarchiveTask = null
)
3 -> TaskColumn(
title = "Archived",
icon = Icons.Default.Archive,
color = MaterialTheme.colorScheme.outline,
count = archivedTasks.size,
tasks = archivedTasks,
onCompleteTask = null,
onEditTask = onEditTask,
onCancelTask = null,
onUncancelTask = null,
onMarkInProgress = null,
onArchiveTask = null,
onUnarchiveTask = onUnarchiveTask
)
}
}
}
}
@Composable
private fun TaskColumn(
title: String,
icon: ImageVector,
color: Color,
count: Int,
tasks: List<TaskDetail>,
onCompleteTask: ((TaskDetail) -> Unit)?,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp)
)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(24.dp)
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = color
)
}
Surface(
color = color,
shape = CircleShape
) {
Text(
text = count.toString(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.surface
)
}
}
// Tasks List
if (tasks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color.copy(alpha = 0.3f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No tasks",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// State for completion history sheet
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tasks, key = { it.id }) { task ->
TaskCard(
task = task,
onCompleteClick = if (onCompleteTask != null) {
{ onCompleteTask(task) }
} else null,
onEditClick = { onEditTask(task) },
onCancelClick = if (onCancelTask != null) {
{ onCancelTask(task) }
} else null,
onUncancelClick = if (onUncancelTask != null) {
{ onUncancelTask(task) }
} else null,
onMarkInProgressClick = if (onMarkInProgress != null) {
{ onMarkInProgress(task) }
} else null,
onArchiveClick = if (onArchiveTask != null) {
{ onArchiveTask(task) }
} else null,
onUnarchiveClick = if (onUnarchiveTask != null) {
{ onUnarchiveTask(task) }
} else null,
onCompletionHistoryClick = if (task.completionCount > 0) {
{ selectedTaskForHistory = task }
} else null
)
}
}
// Completion history sheet
selectedTaskForHistory?.let { task ->
CompletionHistorySheet(
taskId = task.id,
taskTitle = task.title,
onDismiss = { selectedTaskForHistory = null }
)
}
}
}
}
/**
* Dynamic Task Kanban View that creates columns based on API response
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DynamicTaskKanbanView(
columns: List<TaskColumn>,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?,
modifier: Modifier = Modifier,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
val pagerState = rememberPagerState(pageCount = { columns.size })
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxSize(),
pageSpacing = 16.dp,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp)
) { page ->
val column = columns[page]
DynamicTaskColumn(
column = column,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = onMarkInProgress,
onArchiveTask = onArchiveTask,
onUnarchiveTask = onUnarchiveTask,
bottomPadding = bottomPadding
)
}
}
/**
* Dynamic Task Column that adapts based on column configuration
*/
@Composable
private fun DynamicTaskColumn(
column: TaskColumn,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
// Get icon from API response, with fallback
val columnIcon = getIconFromName(column.icons["android"] ?: "List")
val columnColor = hexToColor(column.color)
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = columnIcon,
contentDescription = null,
tint = columnColor,
modifier = Modifier.size(24.dp)
)
Text(
text = column.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = columnColor
)
}
Surface(
color = columnColor,
shape = CircleShape
) {
Text(
text = column.count.toString(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.surface
)
}
}
// Tasks List
if (column.tasks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = columnIcon,
contentDescription = null,
tint = columnColor.copy(alpha = 0.3f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No tasks",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// State for completion history sheet
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 16.dp + bottomPadding
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(column.tasks, key = { it.id }) { task ->
// Use existing TaskCard component with buttonTypes array
TaskCard(
task = task,
buttonTypes = column.buttonTypes,
onCompleteClick = { onCompleteTask(task) },
onEditClick = { onEditTask(task) },
onCancelClick = onCancelTask?.let { { it(task) } },
onUncancelClick = onUncancelTask?.let { { it(task) } },
onMarkInProgressClick = onMarkInProgress?.let { { it(task) } },
onArchiveClick = onArchiveTask?.let { { it(task) } },
onUnarchiveClick = onUnarchiveTask?.let { { it(task) } },
onCompletionHistoryClick = if (task.completionCount > 0) {
{ selectedTaskForHistory = task }
} else null
)
}
}
// Completion history sheet
selectedTaskForHistory?.let { task ->
CompletionHistorySheet(
taskId = task.id,
taskTitle = task.title,
onDismiss = { selectedTaskForHistory = null }
)
}
}
}
}
/**
* Helper function to convert icon name string to ImageVector
*/
private fun getIconFromName(iconName: String): ImageVector {
return when (iconName) {
"CalendarToday" -> Icons.Default.CalendarToday
"PlayCircle" -> Icons.Default.PlayCircle
"CheckCircle" -> Icons.Default.CheckCircle
"Archive" -> Icons.Default.Archive
"List" -> Icons.Default.List
"PlayArrow" -> Icons.Default.PlayArrow
"Unarchive" -> Icons.Default.Unarchive
else -> Icons.Default.List // Default fallback
}
}
/**
* Helper function to convert hex color string to Color
* Supports formats: #RGB, #RRGGBB, #AARRGGBB
* Platform-independent implementation
*/
private fun hexToColor(hex: String): Color {
val cleanHex = hex.removePrefix("#")
return try {
when (cleanHex.length) {
3 -> {
// RGB format - expand to RRGGBB
val r = cleanHex[0].toString().repeat(2).toInt(16)
val g = cleanHex[1].toString().repeat(2).toInt(16)
val b = cleanHex[2].toString().repeat(2).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f)
}
6 -> {
// RRGGBB format
val r = cleanHex.substring(0, 2).toInt(16)
val g = cleanHex.substring(2, 4).toInt(16)
val b = cleanHex.substring(4, 6).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f)
}
8 -> {
// AARRGGBB format
val a = cleanHex.substring(0, 2).toInt(16)
val r = cleanHex.substring(2, 4).toInt(16)
val g = cleanHex.substring(4, 6).toInt(16)
val b = cleanHex.substring(6, 8).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = a / 255f)
}
else -> Color.Gray // Default fallback
}
} catch (e: Exception) {
Color.Gray // Fallback on parse error
}
}

View File

@@ -0,0 +1,42 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun TaskPill(
count: Int,
label: String,
color: Color
) {
Surface(
color = color.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color
)
}
}
}

View File

@@ -0,0 +1,26 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.viewmodel.ResidenceViewModel
@Composable
fun AddDocumentScreen(
residenceId: Int,
initialDocumentType: String = "other",
onNavigateBack: () -> Unit,
onDocumentCreated: () -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
DocumentFormScreen(
residenceId = residenceId,
existingDocumentId = null,
initialDocumentType = initialDocumentType,
onNavigateBack = onNavigateBack,
onSuccess = onDocumentCreated,
documentViewModel = documentViewModel,
residenceViewModel = residenceViewModel
)
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
@Composable
fun AddResidenceScreen(
onNavigateBack: () -> Unit,
onResidenceCreated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
ResidenceFormScreen(
existingResidence = null,
onNavigateBack = onNavigateBack,
onSuccess = onResidenceCreated,
viewModel = viewModel
)
}

View File

@@ -0,0 +1,272 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddNewTaskWithResidenceDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.CompleteTaskDialog
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.task.TaskCard
import com.example.casera.ui.components.task.DynamicTaskKanbanView
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.models.TaskDetail
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AllTasksScreen(
onNavigateToEditTask: (TaskDetail) -> Unit,
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val myResidencesState by residenceViewModel.myResidencesState.collectAsState()
val createTaskState by viewModel.taskAddNewCustomTaskState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
LaunchedEffect(Unit) {
viewModel.loadTasks()
residenceViewModel.loadMyResidences()
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
is ApiResult.Success -> {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
viewModel.loadTasks()
}
else -> {}
}
}
// Handle task creation success
// Handle errors for task creation
createTaskState.HandleErrors(
onRetry = { /* Retry handled in dialog */ },
errorTitle = "Failed to Create Task"
)
LaunchedEffect(createTaskState) {
println("AllTasksScreen: createTaskState changed to $createTaskState")
when (createTaskState) {
is ApiResult.Success -> {
println("AllTasksScreen: Task created successfully, closing dialog and reloading tasks")
showNewTaskDialog = false
viewModel.resetAddTaskState()
viewModel.loadTasks()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"All Tasks",
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(
onClick = { viewModel.loadTasks(forceRefresh = true) }
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh"
)
}
IconButton(
onClick = { showNewTaskDialog = true },
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Task"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
ApiResultHandler(
state = tasksState,
onRetry = { viewModel.loadTasks(forceRefresh = true) },
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Tasks"
) { taskData ->
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Create your first task to get started",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showNewTaskDialog = true },
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Text(
"Add Task",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
if (myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isEmpty()) {
Text(
"Add a property first from the Residences tab",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
} else {
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
selectedTask = task
showCompleteDialog = true
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
// viewModel.cancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onUncancelTask = { task ->
// viewModel.uncancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onMarkInProgress = { task ->
viewModel.markInProgress(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
onArchiveTask = { task ->
viewModel.archiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
onUnarchiveTask = { task ->
viewModel.unarchiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
modifier = Modifier,
bottomPadding = bottomNavBarPadding
)
}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title,
onDismiss = {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
},
onComplete = { request, images ->
if (images.isNotEmpty()) {
taskCompletionViewModel.createTaskCompletionWithImages(
request = request,
images = images
)
} else {
taskCompletionViewModel.createTaskCompletion(request)
}
}
)
}
if (showNewTaskDialog && myResidencesState is ApiResult.Success) {
AddNewTaskWithResidenceDialog(
residencesResponse = (myResidencesState as ApiResult.Success).data,
onDismiss = {
showNewTaskDialog = false
viewModel.resetAddTaskState()
},
onCreate = { taskRequest ->
println("AllTasksScreen: onCreate called with request: $taskRequest")
viewModel.createNewTask(taskRequest)
},
isLoading = createTaskState is ApiResult.Loading,
errorMessage = if (createTaskState is ApiResult.Error) {
com.example.casera.util.ErrorMessageParser.parse((createTaskState as ApiResult.Error).message)
} else null
)
}
}

View File

@@ -0,0 +1,461 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContractorDetailScreen(
contractorId: Int,
onNavigateBack: () -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
val contractorState by viewModel.contractorDetailState.collectAsState()
val deleteState by viewModel.deleteState.collectAsState()
val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState()
var showEditDialog by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
LaunchedEffect(contractorId) {
viewModel.loadContractorDetail(contractorId)
}
// Handle errors for delete contractor
deleteState.HandleErrors(
onRetry = { viewModel.deleteContractor(contractorId) },
errorTitle = "Failed to Delete Contractor"
)
// Handle errors for toggle favorite
toggleFavoriteState.HandleErrors(
onRetry = { viewModel.toggleFavorite(contractorId) },
errorTitle = "Failed to Update Favorite"
)
LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) {
viewModel.resetDeleteState()
onNavigateBack()
}
}
LaunchedEffect(toggleFavoriteState) {
if (toggleFavoriteState is ApiResult.Success) {
viewModel.loadContractorDetail(contractorId)
viewModel.resetToggleFavoriteState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Contractor Details", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
when (val state = contractorState) {
is ApiResult.Success -> {
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
Icon(
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
"Toggle favorite",
tint = if (state.data.isFavorite) Color(0xFFF59E0B) else LocalContentColor.current
)
}
IconButton(onClick = { showEditDialog = true }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton(onClick = { showDeleteConfirmation = true }) {
Icon(Icons.Default.Delete, "Delete", tint = Color(0xFFEF4444))
}
}
else -> {}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFFF9FAFB)
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFFF9FAFB))
) {
ApiResultHandler(
state = contractorState,
onRetry = { viewModel.loadContractorDetail(contractorId) },
errorTitle = "Failed to Load Contractor",
loadingContent = {
CircularProgressIndicator(color = Color(0xFF2563EB))
}
) { contractor ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Avatar
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Color(0xFFEEF2FF)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color(0xFF3B82F6)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = contractor.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFF111827)
)
if (contractor.company != null) {
Text(
text = contractor.company,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF6B7280)
)
}
if (contractor.specialty != null) {
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(20.dp),
color = Color(0xFFEEF2FF)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.WorkOutline,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF3B82F6)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = contractor.specialty,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF3B82F6),
fontWeight = FontWeight.Medium
)
}
}
}
if (contractor.averageRating != null && contractor.averageRating > 0) {
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(5) { index ->
Icon(
if (index < contractor.averageRating.toInt()) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFFF59E0B)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${(contractor.averageRating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF111827)
)
}
}
if (contractor.taskCount > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${contractor.taskCount} completed tasks",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280)
)
}
}
}
}
// Contact Information
item {
DetailSection(title = "Contact Information") {
if (contractor.phone != null) {
DetailRow(
icon = Icons.Default.Phone,
label = "Phone",
value = contractor.phone,
iconTint = Color(0xFF3B82F6)
)
}
if (contractor.email != null) {
DetailRow(
icon = Icons.Default.Email,
label = "Email",
value = contractor.email,
iconTint = Color(0xFF8B5CF6)
)
}
if (contractor.secondaryPhone != null) {
DetailRow(
icon = Icons.Default.Phone,
label = "Secondary Phone",
value = contractor.secondaryPhone,
iconTint = Color(0xFF10B981)
)
}
if (contractor.website != null) {
DetailRow(
icon = Icons.Default.Language,
label = "Website",
value = contractor.website,
iconTint = Color(0xFFF59E0B)
)
}
}
}
// Business Details
if (contractor.licenseNumber != null || contractor.specialty != null) {
item {
DetailSection(title = "Business Details") {
if (contractor.licenseNumber != null) {
DetailRow(
icon = Icons.Default.Badge,
label = "License Number",
value = contractor.licenseNumber,
iconTint = Color(0xFF3B82F6)
)
}
}
}
}
// Address
if (contractor.address != null || contractor.city != null) {
item {
DetailSection(title = "Address") {
val fullAddress = buildString {
contractor.address?.let { append(it) }
if (contractor.city != null || contractor.state != null || contractor.zipCode != null) {
if (isNotEmpty()) append("\n")
contractor.city?.let { append(it) }
contractor.state?.let {
if (contractor.city != null) append(", ")
append(it)
}
contractor.zipCode?.let {
append(" ")
append(it)
}
}
}
if (fullAddress.isNotBlank()) {
DetailRow(
icon = Icons.Default.LocationOn,
label = "Location",
value = fullAddress,
iconTint = Color(0xFFEF4444)
)
}
}
}
}
// Notes
if (contractor.notes != null) {
item {
DetailSection(title = "Notes") {
Text(
text = contractor.notes,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF374151),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
}
// Task History
item {
DetailSection(title = "Task History") {
// Placeholder for task history
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF10B981)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${contractor.taskCount} completed tasks",
color = Color(0xFF6B7280),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
if (showEditDialog) {
AddContractorDialog(
contractorId = contractorId,
onDismiss = { showEditDialog = false },
onContractorSaved = {
showEditDialog = false
viewModel.loadContractorDetail(contractorId)
}
)
}
if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
icon = { Icon(Icons.Default.Warning, null, tint = Color(0xFFEF4444)) },
title = { Text("Delete Contractor") },
text = { Text("Are you sure you want to delete this contractor? This action cannot be undone.") },
confirmButton = {
Button(
onClick = {
viewModel.deleteContractor(contractorId)
showDeleteConfirmation = false
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444))
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text("Cancel")
}
},
containerColor = Color.White,
shape = RoundedCornerShape(16.dp)
)
}
}
@Composable
fun DetailSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827),
modifier = Modifier.padding(16.dp).padding(bottom = 0.dp)
)
content()
}
}
}
@Composable
fun DetailRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String,
iconTint: Color = Color(0xFF6B7280)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = iconTint
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF111827),
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,527 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.ContractorSummary
import com.example.casera.network.ApiResult
import com.example.casera.repository.LookupsRepository
import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContractorsScreen(
onNavigateBack: () -> Unit,
onNavigateToContractorDetail: (Int) -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
val contractorsState by viewModel.contractorsState.collectAsState()
val deleteState by viewModel.deleteState.collectAsState()
val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState()
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
// Check if screen should be blocked (limit=0)
val isBlocked = SubscriptionHelper.isContractorsBlocked()
// Get current count for checking when adding
val currentCount = (contractorsState as? ApiResult.Success)?.data?.size ?: 0
var showAddDialog by remember { mutableStateOf(false) }
var showUpgradeDialog by remember { mutableStateOf(false) }
var selectedFilter by remember { mutableStateOf<String?>(null) }
var showFavoritesOnly by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showFiltersMenu by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadContractors()
}
// Handle refresh state
LaunchedEffect(contractorsState) {
if (contractorsState !is ApiResult.Loading) {
isRefreshing = false
}
}
LaunchedEffect(selectedFilter, showFavoritesOnly, searchQuery) {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
}
// Handle errors for delete contractor
deleteState.HandleErrors(
onRetry = { /* Handled in UI */ },
errorTitle = "Failed to Delete Contractor"
)
// Handle errors for toggle favorite
toggleFavoriteState.HandleErrors(
onRetry = { /* Handled in UI */ },
errorTitle = "Failed to Update Favorite"
)
LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) {
viewModel.loadContractors()
viewModel.resetDeleteState()
}
}
LaunchedEffect(toggleFavoriteState) {
if (toggleFavoriteState is ApiResult.Success) {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
viewModel.resetToggleFavoriteState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Contractors", fontWeight = FontWeight.Bold) },
actions = {
// Favorites filter toggle
IconButton(onClick = { showFavoritesOnly = !showFavoritesOnly }) {
Icon(
if (showFavoritesOnly) Icons.Default.Star else Icons.Default.StarOutline,
"Filter favorites",
tint = if (showFavoritesOnly) MaterialTheme.colorScheme.tertiary else LocalContentColor.current
)
}
// Specialty filter menu
Box {
IconButton(onClick = { showFiltersMenu = true }) {
Icon(
Icons.Default.FilterList,
"Filter by specialty",
tint = if (selectedFilter != null) MaterialTheme.colorScheme.primary else LocalContentColor.current
)
}
DropdownMenu(
expanded = showFiltersMenu,
onDismissRequest = { showFiltersMenu = false }
) {
DropdownMenuItem(
text = { Text("All Specialties") },
onClick = {
selectedFilter = null
showFiltersMenu = false
},
leadingIcon = {
if (selectedFilter == null) {
Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.secondary)
}
}
)
HorizontalDivider()
contractorSpecialties.forEach { specialty ->
DropdownMenuItem(
text = { Text(specialty.name) },
onClick = {
selectedFilter = specialty.name
showFiltersMenu = false
},
leadingIcon = {
if (selectedFilter == specialty.name) {
Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.secondary)
}
}
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Don't show FAB if screen is blocked (limit=0)
if (!isBlocked.allowed) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = {
// Check if user can add based on current count
val canAdd = SubscriptionHelper.canAddContractor(currentCount)
if (canAdd.allowed) {
showAddDialog = true
} else {
showUpgradeDialog = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.Add, "Add contractor")
}
}
}
}
) { padding ->
// Show upgrade prompt for the entire screen if blocked (limit=0)
if (isBlocked.allowed) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
UpgradeFeatureScreen(
triggerKey = isBlocked.triggerKey ?: "view_contractors",
icon = Icons.Default.People,
onNavigateBack = onNavigateBack
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background)
) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search contractors...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, "Clear search")
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)
// Active filters display
if (selectedFilter != null || showFavoritesOnly) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (showFavoritesOnly) {
FilterChip(
selected = true,
onClick = { showFavoritesOnly = false },
label = { Text("Favorites") },
leadingIcon = { Icon(Icons.Default.Star, null, modifier = Modifier.size(16.dp)) }
)
}
if (selectedFilter != null) {
FilterChip(
selected = true,
onClick = { selectedFilter = null },
label = { Text(selectedFilter!!) },
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp)) }
)
}
}
}
ApiResultHandler(
state = contractorsState,
onRetry = {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
},
errorTitle = "Failed to Load Contractors",
loadingContent = {
if (!isRefreshing) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
) { contractors ->
if (contractors.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
"No contractors found"
else
"No contractors yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
Text(
"Add your first contractor to get started",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
}
}
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(contractors, key = { it.id }) { contractor ->
ContractorCard(
contractor = contractor,
onToggleFavorite = { viewModel.toggleFavorite(it) },
onClick = { onNavigateToContractorDetail(it) }
)
}
}
}
}
}
}
}
}
if (showAddDialog) {
AddContractorDialog(
onDismiss = { showAddDialog = false },
onContractorSaved = {
showAddDialog = false
viewModel.loadContractors()
}
)
}
// Show upgrade dialog when user hits limit
if (showUpgradeDialog) {
AlertDialog(
onDismissRequest = { showUpgradeDialog = false },
title = { Text("Upgrade Required") },
text = {
Text("You've reached the maximum number of contractors for your current plan. Upgrade to Pro for unlimited contractors.")
},
confirmButton = {
TextButton(onClick = { showUpgradeDialog = false }) {
Text("OK")
}
}
)
}
}
@Composable
fun ContractorCard(
contractor: ContractorSummary,
onToggleFavorite: (Int) -> Unit,
onClick: (Int) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(contractor.id) },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar/Icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = contractor.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (contractor.isFavorite) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Star,
contentDescription = "Favorite",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
}
if (contractor.company != null) {
Text(
text = contractor.company,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (contractor.specialty != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.WorkOutline,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = contractor.specialty,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (contractor.averageRating != null && contractor.averageRating > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${(contractor.averageRating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
}
if (contractor.taskCount > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${contractor.taskCount} tasks",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Favorite toggle button
IconButton(
onClick = { onToggleFavorite(contractor.id) }
) {
Icon(
if (contractor.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = if (contractor.isFavorite) "Remove from favorites" else "Add to favorites",
tint = if (contractor.isFavorite) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Arrow icon
Icon(
Icons.Default.ChevronRight,
contentDescription = "View details",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,701 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import androidx.compose.foundation.Image
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import androidx.compose.ui.window.Dialog
import com.example.casera.ui.components.documents.ErrorState
import com.example.casera.ui.components.documents.formatFileSize
import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentDetailScreen(
documentId: Int,
onNavigateBack: () -> Unit,
onNavigateToEdit: (Int) -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
) {
val documentState by documentViewModel.documentDetailState.collectAsState()
val deleteState by documentViewModel.deleteState.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) }
var showPhotoViewer by remember { mutableStateOf(false) }
var selectedPhotoIndex by remember { mutableStateOf(0) }
LaunchedEffect(documentId) {
documentViewModel.loadDocumentDetail(documentId)
}
// Handle errors for document deletion
deleteState.HandleErrors(
onRetry = { documentViewModel.deleteDocument(documentId) },
errorTitle = "Failed to Delete Document"
)
// Handle successful deletion
LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) {
documentViewModel.resetDeleteState()
onNavigateBack()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Document Details", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
when (documentState) {
is ApiResult.Success -> {
IconButton(onClick = { onNavigateToEdit(documentId) }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, "Delete", tint = Color.Red)
}
}
else -> {}
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
ApiResultHandler(
state = documentState,
onRetry = { documentViewModel.loadDocumentDetail(documentId) },
errorTitle = "Failed to Load Document"
) { document ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Status badge (for warranties)
if (document.documentType == "warranty") {
val daysUntilExpiration = document.daysUntilExpiration ?: 0
val statusColor = when {
!document.isActive -> Color.Gray
daysUntilExpiration < 0 -> Color.Red
daysUntilExpiration < 30 -> Color(0xFFF59E0B)
daysUntilExpiration < 90 -> Color(0xFFFBBF24)
else -> Color(0xFF10B981)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = statusColor.copy(alpha = 0.1f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Status",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Text(
when {
!document.isActive -> "Inactive"
daysUntilExpiration < 0 -> "Expired"
daysUntilExpiration < 30 -> "Expiring soon"
else -> "Active"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = statusColor
)
}
if (document.isActive && daysUntilExpiration >= 0) {
Column(horizontalAlignment = Alignment.End) {
Text(
"Days Remaining",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Text(
"$daysUntilExpiration",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = statusColor
)
}
}
}
}
}
// Basic Information
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Basic Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
DetailRow("Title", document.title)
DetailRow("Type", DocumentType.fromValue(document.documentType).displayName)
document.category?.let {
DetailRow("Category", DocumentCategory.fromValue(it).displayName)
}
document.description?.let {
DetailRow("Description", it)
}
}
}
// Warranty/Item Details (for warranties)
if (document.documentType == "warranty" &&
(document.itemName != null || document.modelNumber != null ||
document.serialNumber != null || document.provider != null)) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Item Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.itemName?.let { DetailRow("Item Name", it) }
document.modelNumber?.let { DetailRow("Model Number", it) }
document.serialNumber?.let { DetailRow("Serial Number", it) }
document.provider?.let { DetailRow("Provider", it) }
document.providerContact?.let { DetailRow("Provider Contact", it) }
}
}
}
// Claim Information (for warranties)
if (document.documentType == "warranty" &&
(document.claimPhone != null || document.claimEmail != null ||
document.claimWebsite != null)) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Claim Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.claimPhone?.let { DetailRow("Claim Phone", it) }
document.claimEmail?.let { DetailRow("Claim Email", it) }
document.claimWebsite?.let { DetailRow("Claim Website", it) }
}
}
}
// Dates
if (document.purchaseDate != null || document.startDate != null ||
document.endDate != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Important Dates",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.purchaseDate?.let { DetailRow("Purchase Date", it) }
document.startDate?.let { DetailRow("Start Date", it) }
document.endDate?.let { DetailRow("End Date", it) }
}
}
}
// Residence & Contractor
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Associations",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.residenceAddress?.let { DetailRow("Residence", it) }
document.contractorName?.let { DetailRow("Contractor", it) }
document.contractorPhone?.let { DetailRow("Contractor Phone", it) }
}
}
// Additional Information
if (document.tags != null || document.notes != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Additional Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.tags?.let { DetailRow("Tags", it) }
document.notes?.let { DetailRow("Notes", it) }
}
}
}
// Images
if (document.images.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Images (${document.images.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
// Image grid
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
document.images.take(4).forEachIndexed { index, image ->
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clickable {
selectedPhotoIndex = index
showPhotoViewer = true
}
) {
AsyncImage(
model = image.imageUrl,
contentDescription = image.caption,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
if (index == 3 && document.images.size > 4) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Text(
"+${document.images.size - 4}",
color = Color.White,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
}
// File Information
if (document.fileUrl != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Attached File",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.fileType?.let { DetailRow("File Type", it) }
document.fileSize?.let {
DetailRow("File Size", formatFileSize(it))
}
Button(
onClick = { /* TODO: Download file */ },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Download, null)
Spacer(modifier = Modifier.width(8.dp))
Text("Download File")
}
}
}
}
// Metadata
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Metadata",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.uploadedByUsername?.let { DetailRow("Uploaded By", it) }
document.createdAt?.let { DetailRow("Created", it) }
document.updatedAt?.let { DetailRow("Updated", it) }
}
}
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Document") },
text = { Text("Are you sure you want to delete this document? This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
documentViewModel.deleteDocument(documentId)
showDeleteDialog = false
}
) {
Text("Delete", color = Color.Red)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
// Photo viewer dialog
if (showPhotoViewer && documentState is ApiResult.Success) {
val document = (documentState as ApiResult.Success<Document>).data
if (document.images.isNotEmpty()) {
DocumentImageViewer(
images = document.images,
initialIndex = selectedPhotoIndex,
onDismiss = { showPhotoViewer = false }
)
}
}
}
@Composable
fun DetailRow(label: String, value: String) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Spacer(modifier = Modifier.height(4.dp))
Text(
value,
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
fun DocumentImageViewer(
images: List<DocumentImage>,
initialIndex: Int = 0,
onDismiss: () -> Unit
) {
var selectedIndex by remember { mutableStateOf(initialIndex) }
var showFullImage by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = {
if (showFullImage) {
showFullImage = false
} else {
onDismiss()
}
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (showFullImage) "Image ${selectedIndex + 1} of ${images.size}" else "Document Images",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = {
if (showFullImage) {
showFullImage = false
} else {
onDismiss()
}
}) {
Icon(
Icons.Default.Close,
contentDescription = "Close"
)
}
}
HorizontalDivider()
// Content
if (showFullImage) {
// Single image view
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
SubcomposeAsyncImage(
model = images[selectedIndex].imageUrl,
contentDescription = images[selectedIndex].caption,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentScale = androidx.compose.ui.layout.ContentScale.Fit
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AsyncImagePainter.State.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error loading image",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Failed to load image",
color = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
images[selectedIndex].caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = caption,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
// Navigation buttons
if (images.size > 1) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
onClick = { selectedIndex = (selectedIndex - 1 + images.size) % images.size },
enabled = selectedIndex > 0
) {
Icon(Icons.Default.ArrowBack, "Previous")
Spacer(modifier = Modifier.width(8.dp))
Text("Previous")
}
Button(
onClick = { selectedIndex = (selectedIndex + 1) % images.size },
enabled = selectedIndex < images.size - 1
) {
Text("Next")
Spacer(modifier = Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, "Next")
}
}
}
}
} else {
// Grid view
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(images.size) { index ->
val image = images[index]
Card(
onClick = {
selectedIndex = index
showFullImage = true
},
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
SubcomposeAsyncImage(
model = image.imageUrl,
contentDescription = image.caption,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
}
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
image.caption?.let { caption ->
Text(
text = caption,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,713 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import com.example.casera.platform.ImageData
import com.example.casera.platform.rememberImagePicker
import com.example.casera.platform.rememberCameraPicker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentFormScreen(
residenceId: Int? = null,
existingDocumentId: Int? = null,
initialDocumentType: String = "other",
onNavigateBack: () -> Unit,
onSuccess: () -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val isEditMode = existingDocumentId != null
val needsResidenceSelection = residenceId == null || residenceId == -1
// State
var selectedResidence by remember { mutableStateOf<Residence?>(null) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedDocumentType by remember { mutableStateOf(initialDocumentType) }
var selectedCategory by remember { mutableStateOf<String?>(null) }
var notes by remember { mutableStateOf("") }
var tags by remember { mutableStateOf("") }
var isActive by remember { mutableStateOf(true) }
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
var existingImageUrls by remember { mutableStateOf<List<String>>(emptyList()) }
// Warranty-specific fields
var itemName by remember { mutableStateOf("") }
var modelNumber by remember { mutableStateOf("") }
var serialNumber by remember { mutableStateOf("") }
var provider by remember { mutableStateOf("") }
var providerContact by remember { mutableStateOf("") }
var claimPhone by remember { mutableStateOf("") }
var claimEmail by remember { mutableStateOf("") }
var claimWebsite by remember { mutableStateOf("") }
var purchaseDate by remember { mutableStateOf("") }
var startDate by remember { mutableStateOf("") }
var endDate by remember { mutableStateOf("") }
// Dropdowns
var documentTypeExpanded by remember { mutableStateOf(false) }
var categoryExpanded by remember { mutableStateOf(false) }
var residenceExpanded by remember { mutableStateOf(false) }
// Validation errors
var titleError by remember { mutableStateOf("") }
var itemNameError by remember { mutableStateOf("") }
var providerError by remember { mutableStateOf("") }
var residenceError by remember { mutableStateOf("") }
val residencesState by residenceViewModel.residencesState.collectAsState()
val documentDetailState by documentViewModel.documentDetailState.collectAsState()
val operationState by if (isEditMode) {
documentViewModel.updateState.collectAsState()
} else {
documentViewModel.createState.collectAsState()
}
val isWarranty = selectedDocumentType == "warranty"
val maxImages = if (isEditMode) 10 else 5
// Image pickers
val imagePicker = rememberImagePicker { images ->
selectedImages = if (selectedImages.size + images.size <= maxImages) {
selectedImages + images
} else {
selectedImages + images.take(maxImages - selectedImages.size)
}
}
val cameraPicker = rememberCameraPicker { image ->
if (selectedImages.size < maxImages) {
selectedImages = selectedImages + image
}
}
// Load residences if needed
LaunchedEffect(needsResidenceSelection) {
if (needsResidenceSelection) {
residenceViewModel.loadResidences()
}
}
// Load existing document for edit mode
LaunchedEffect(existingDocumentId) {
if (existingDocumentId != null) {
documentViewModel.loadDocumentDetail(existingDocumentId)
}
}
// Populate form from existing document
LaunchedEffect(documentDetailState) {
if (isEditMode && documentDetailState is ApiResult.Success) {
val document = (documentDetailState as ApiResult.Success<Document>).data
title = document.title
selectedDocumentType = document.documentType
description = document.description ?: ""
selectedCategory = document.category
tags = document.tags ?: ""
notes = document.notes ?: ""
isActive = document.isActive
existingImageUrls = document.images.map { it.imageUrl }
// Warranty fields
itemName = document.itemName ?: ""
modelNumber = document.modelNumber ?: ""
serialNumber = document.serialNumber ?: ""
provider = document.provider ?: ""
providerContact = document.providerContact ?: ""
claimPhone = document.claimPhone ?: ""
claimEmail = document.claimEmail ?: ""
claimWebsite = document.claimWebsite ?: ""
purchaseDate = document.purchaseDate ?: ""
startDate = document.startDate ?: ""
endDate = document.endDate ?: ""
}
}
// Handle success
LaunchedEffect(operationState) {
if (operationState is ApiResult.Success) {
if (isEditMode) {
documentViewModel.resetUpdateState()
} else {
documentViewModel.resetCreateState()
}
onSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
when {
isEditMode && isWarranty -> "Edit Warranty"
isEditMode -> "Edit Document"
isWarranty -> "Add Warranty"
else -> "Add Document"
}
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Loading state for edit mode
if (isEditMode && documentDetailState is ApiResult.Loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return@Column
}
// Residence Dropdown (if needed)
if (needsResidenceSelection) {
when (residencesState) {
is ApiResult.Loading -> {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
}
is ApiResult.Success -> {
val residences = (residencesState as ApiResult.Success<List<Residence>>).data
ExposedDropdownMenuBox(
expanded = residenceExpanded,
onExpandedChange = { residenceExpanded = it }
) {
OutlinedTextField(
value = selectedResidence?.name ?: "Select Residence",
onValueChange = {},
readOnly = true,
label = { Text("Residence *") },
isError = residenceError.isNotEmpty(),
supportingText = if (residenceError.isNotEmpty()) {
{ Text(residenceError) }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = residenceExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = residenceExpanded,
onDismissRequest = { residenceExpanded = false }
) {
residences.forEach { residence ->
DropdownMenuItem(
text = { Text(residence.name) },
onClick = {
selectedResidence = residence
residenceError = ""
residenceExpanded = false
}
)
}
}
}
}
is ApiResult.Error -> {
Text(
"Failed to load residences: ${com.example.casera.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.error
)
}
else -> {}
}
}
// Document Type Dropdown
ExposedDropdownMenuBox(
expanded = documentTypeExpanded,
onExpandedChange = { documentTypeExpanded = it }
) {
OutlinedTextField(
value = DocumentType.fromValue(selectedDocumentType).displayName,
onValueChange = {},
readOnly = true,
label = { Text("Document Type *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = documentTypeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = documentTypeExpanded,
onDismissRequest = { documentTypeExpanded = false }
) {
DocumentType.values().forEach { type ->
DropdownMenuItem(
text = { Text(type.displayName) },
onClick = {
selectedDocumentType = type.value
documentTypeExpanded = false
}
)
}
}
}
// Title
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = ""
},
label = { Text("Title *") },
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null,
modifier = Modifier.fillMaxWidth()
)
// Warranty-specific fields
if (isWarranty) {
OutlinedTextField(
value = itemName,
onValueChange = {
itemName = it
itemNameError = ""
},
label = { Text("Item Name *") },
isError = itemNameError.isNotEmpty(),
supportingText = if (itemNameError.isNotEmpty()) {
{ Text(itemNameError) }
} else null,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = modelNumber,
onValueChange = { modelNumber = it },
label = { Text("Model Number") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = serialNumber,
onValueChange = { serialNumber = it },
label = { Text("Serial Number") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = provider,
onValueChange = {
provider = it
providerError = ""
},
label = { Text("Provider/Company *") },
isError = providerError.isNotEmpty(),
supportingText = if (providerError.isNotEmpty()) {
{ Text(providerError) }
} else null,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = providerContact,
onValueChange = { providerContact = it },
label = { Text("Provider Contact") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimPhone,
onValueChange = { claimPhone = it },
label = { Text("Claim Phone") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimEmail,
onValueChange = { claimEmail = it },
label = { Text("Claim Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimWebsite,
onValueChange = { claimWebsite = it },
label = { Text("Claim Website") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = purchaseDate,
onValueChange = { purchaseDate = it },
label = { Text("Purchase Date (YYYY-MM-DD)") },
placeholder = { Text("2024-01-15") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = startDate,
onValueChange = { startDate = it },
label = { Text("Warranty Start Date (YYYY-MM-DD)") },
placeholder = { Text("2024-01-15") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = endDate,
onValueChange = { endDate = it },
label = { Text("Warranty End Date (YYYY-MM-DD) *") },
placeholder = { Text("2025-01-15") },
modifier = Modifier.fillMaxWidth()
)
}
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
// Category Dropdown (for warranties and some documents)
if (isWarranty || selectedDocumentType in listOf("inspection", "manual", "receipt")) {
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) {
OutlinedTextField(
value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: "Select Category",
onValueChange = {},
readOnly = true,
label = { Text("Category") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = categoryExpanded,
onDismissRequest = { categoryExpanded = false }
) {
DropdownMenuItem(
text = { Text("None") },
onClick = {
selectedCategory = null
categoryExpanded = false
}
)
DocumentCategory.values().forEach { category ->
DropdownMenuItem(
text = { Text(category.displayName) },
onClick = {
selectedCategory = category.value
categoryExpanded = false
}
)
}
}
}
}
// Tags
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
label = { Text("Tags") },
placeholder = { Text("tag1, tag2, tag3") },
modifier = Modifier.fillMaxWidth()
)
// Notes
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes") },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
// Active toggle (edit mode only)
if (isEditMode) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Active")
Switch(
checked = isActive,
onCheckedChange = { isActive = it }
)
}
}
// Existing images (edit mode only)
if (isEditMode && existingImageUrls.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Existing Photos (${existingImageUrls.size})",
style = MaterialTheme.typography.titleSmall
)
existingImageUrls.forEach { url ->
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
}
}
}
// Image upload section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"${if (isEditMode) "New " else ""}Photos (${selectedImages.size}/$maxImages)",
style = MaterialTheme.typography.titleSmall
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { cameraPicker() },
modifier = Modifier.weight(1f),
enabled = selectedImages.size < maxImages
) {
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Camera")
}
Button(
onClick = { imagePicker() },
modifier = Modifier.weight(1f),
enabled = selectedImages.size < maxImages
) {
Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Gallery")
}
}
// Display selected images
if (selectedImages.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
selectedImages.forEachIndexed { index, image ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"Image ${index + 1}",
style = MaterialTheme.typography.bodyMedium
)
}
IconButton(
onClick = {
selectedImages = selectedImages.filter { it != image }
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove image",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
// Error message
if (operationState is ApiResult.Error) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
com.example.casera.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
// Save Button
Button(
onClick = {
// Validate
var hasError = false
// Determine the actual residenceId to use
val actualResidenceId = if (needsResidenceSelection) {
if (selectedResidence == null) {
residenceError = "Please select a residence"
hasError = true
-1
} else {
selectedResidence!!.id
}
} else {
residenceId ?: -1
}
if (title.isBlank()) {
titleError = "Title is required"
hasError = true
}
if (isWarranty) {
if (itemName.isBlank()) {
itemNameError = "Item name is required for warranties"
hasError = true
}
if (provider.isBlank()) {
providerError = "Provider is required for warranties"
hasError = true
}
}
if (!hasError) {
if (isEditMode && existingDocumentId != null) {
documentViewModel.updateDocument(
id = existingDocumentId,
title = title,
documentType = selectedDocumentType,
description = description.ifBlank { null },
category = selectedCategory,
tags = tags.ifBlank { null },
notes = notes.ifBlank { null },
isActive = isActive,
itemName = if (isWarranty) itemName else null,
modelNumber = modelNumber.ifBlank { null },
serialNumber = serialNumber.ifBlank { null },
provider = if (isWarranty) provider else null,
providerContact = providerContact.ifBlank { null },
claimPhone = claimPhone.ifBlank { null },
claimEmail = claimEmail.ifBlank { null },
claimWebsite = claimWebsite.ifBlank { null },
purchaseDate = purchaseDate.ifBlank { null },
startDate = startDate.ifBlank { null },
endDate = endDate.ifBlank { null },
images = selectedImages
)
} else {
documentViewModel.createDocument(
title = title,
documentType = selectedDocumentType,
residenceId = actualResidenceId,
description = description.ifBlank { null },
category = selectedCategory,
tags = tags.ifBlank { null },
notes = notes.ifBlank { null },
contractorId = null,
isActive = true,
itemName = if (isWarranty) itemName else null,
modelNumber = modelNumber.ifBlank { null },
serialNumber = serialNumber.ifBlank { null },
provider = if (isWarranty) provider else null,
providerContact = providerContact.ifBlank { null },
claimPhone = claimPhone.ifBlank { null },
claimEmail = claimEmail.ifBlank { null },
claimWebsite = claimWebsite.ifBlank { null },
purchaseDate = purchaseDate.ifBlank { null },
startDate = startDate.ifBlank { null },
endDate = endDate.ifBlank { null },
images = selectedImages
)
}
}
},
modifier = Modifier.fillMaxWidth(),
enabled = operationState !is ApiResult.Loading
) {
if (operationState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(
when {
isEditMode && isWarranty -> "Update Warranty"
isEditMode -> "Update Document"
isWarranty -> "Add Warranty"
else -> "Add Document"
}
)
}
}
}
}
}

View File

@@ -0,0 +1,257 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.documents.DocumentsTabContent
import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.models.*
enum class DocumentTab {
WARRANTIES, DOCUMENTS
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentsScreen(
onNavigateBack: () -> Unit,
residenceId: Int? = null,
onNavigateToAddDocument: (residenceId: Int, documentType: String) -> Unit = { _, _ -> },
onNavigateToDocumentDetail: (documentId: Int) -> Unit = {},
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
) {
var selectedTab by remember { mutableStateOf(DocumentTab.WARRANTIES) }
val documentsState by documentViewModel.documentsState.collectAsState()
// Check if screen should be blocked (limit=0)
val isBlocked = SubscriptionHelper.isDocumentsBlocked()
// Get current count for checking when adding
val currentCount = (documentsState as? com.example.casera.network.ApiResult.Success)?.data?.size ?: 0
var selectedCategory by remember { mutableStateOf<String?>(null) }
var selectedDocType by remember { mutableStateOf<String?>(null) }
var showActiveOnly by remember { mutableStateOf(true) }
var showFiltersMenu by remember { mutableStateOf(false) }
var showUpgradeDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Load warranties by default (documentType="warranty")
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
isActive = true
)
}
LaunchedEffect(selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
if (selectedTab == DocumentTab.WARRANTIES) {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
} else {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = selectedDocType
)
}
}
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Documents & Warranties", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
if (selectedTab == DocumentTab.WARRANTIES) {
// Active filter toggle for warranties
IconButton(onClick = { showActiveOnly = !showActiveOnly }) {
Icon(
if (showActiveOnly) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
"Filter active",
tint = if (showActiveOnly) MaterialTheme.colorScheme.secondary else LocalContentColor.current
)
}
}
// Filter menu
Box {
IconButton(onClick = { showFiltersMenu = true }) {
Icon(
Icons.Default.FilterList,
"Filters",
tint = if (selectedCategory != null || selectedDocType != null)
MaterialTheme.colorScheme.primary else LocalContentColor.current
)
}
DropdownMenu(
expanded = showFiltersMenu,
onDismissRequest = { showFiltersMenu = false }
) {
if (selectedTab == DocumentTab.WARRANTIES) {
DropdownMenuItem(
text = { Text("All Categories") },
onClick = {
selectedCategory = null
showFiltersMenu = false
}
)
Divider()
DocumentCategory.values().forEach { category ->
DropdownMenuItem(
text = { Text(category.displayName) },
onClick = {
selectedCategory = category.value
showFiltersMenu = false
}
)
}
} else {
DropdownMenuItem(
text = { Text("All Types") },
onClick = {
selectedDocType = null
showFiltersMenu = false
}
)
Divider()
DocumentType.values().forEach { type ->
DropdownMenuItem(
text = { Text(type.displayName) },
onClick = {
selectedDocType = type.value
showFiltersMenu = false
}
)
}
}
}
}
}
)
// Tabs
TabRow(selectedTabIndex = selectedTab.ordinal) {
Tab(
selected = selectedTab == DocumentTab.WARRANTIES,
onClick = { selectedTab = DocumentTab.WARRANTIES },
text = { Text("Warranties") },
icon = { Icon(Icons.Default.VerifiedUser, null) }
)
Tab(
selected = selectedTab == DocumentTab.DOCUMENTS,
onClick = { selectedTab = DocumentTab.DOCUMENTS },
text = { Text("Documents") },
icon = { Icon(Icons.Default.Description, null) }
)
}
}
},
floatingActionButton = {
// Don't show FAB if screen is blocked (limit=0)
if (!isBlocked.allowed) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = {
// Check if user can add based on current count
val canAdd = SubscriptionHelper.canAddDocument(currentCount)
if (canAdd.allowed) {
val documentType = if (selectedTab == DocumentTab.WARRANTIES) "warranty" else "other"
// Pass residenceId even if null - AddDocumentScreen will handle it
onNavigateToAddDocument(residenceId ?: -1, documentType)
} else {
showUpgradeDialog = true
}
},
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, "Add")
}
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (isBlocked.allowed) {
// Screen is blocked (limit=0) - show upgrade prompt
UpgradeFeatureScreen(
triggerKey = isBlocked.triggerKey ?: "view_documents",
icon = Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see normal content
when (selectedTab) {
DocumentTab.WARRANTIES -> {
DocumentsTabContent(
state = documentsState,
isWarrantyTab = true,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
},
onNavigateBack = onNavigateBack
)
}
DocumentTab.DOCUMENTS -> {
DocumentsTabContent(
state = documentsState,
isWarrantyTab = false,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = selectedDocType
)
},
onNavigateBack = onNavigateBack
)
}
}
}
}
}
// Show upgrade dialog when user hits limit
if (showUpgradeDialog) {
AlertDialog(
onDismissRequest = { showUpgradeDialog = false },
title = { Text("Upgrade Required") },
text = {
Text("You've reached the maximum number of documents for your current plan. Upgrade to Pro for unlimited documents.")
},
confirmButton = {
TextButton(onClick = { showUpgradeDialog = false }) {
Text("OK")
}
}
)
}
}

View File

@@ -0,0 +1,21 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.DocumentViewModel
@Composable
fun EditDocumentScreen(
documentId: Int,
onNavigateBack: () -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
) {
DocumentFormScreen(
residenceId = null,
existingDocumentId = documentId,
initialDocumentType = "other",
onNavigateBack = onNavigateBack,
onSuccess = onNavigateBack,
documentViewModel = documentViewModel
)
}

View File

@@ -0,0 +1,21 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.models.Residence
@Composable
fun EditResidenceScreen(
residence: Residence,
onNavigateBack: () -> Unit,
onResidenceUpdated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
ResidenceFormScreen(
existingResidence = residence,
onNavigateBack = onNavigateBack,
onSuccess = onResidenceUpdated,
viewModel = viewModel
)
}

View File

@@ -0,0 +1,335 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.*
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTaskScreen(
task: TaskDetail,
onNavigateBack: () -> Unit,
onTaskUpdated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
var title by remember { mutableStateOf(task.title) }
var description by remember { mutableStateOf(task.description ?: "") }
var selectedCategory by remember { mutableStateOf<TaskCategory?>(task.category) }
var selectedFrequency by remember { mutableStateOf<TaskFrequency?>(task.frequency) }
var selectedPriority by remember { mutableStateOf<TaskPriority?>(task.priority) }
var selectedStatus by remember { mutableStateOf<TaskStatus?>(task.status) }
var dueDate by remember { mutableStateOf(task.dueDate ?: "") }
var estimatedCost by remember { mutableStateOf(task.estimatedCost?.toString() ?: "") }
var categoryExpanded by remember { mutableStateOf(false) }
var frequencyExpanded by remember { mutableStateOf(false) }
var priorityExpanded by remember { mutableStateOf(false) }
var statusExpanded by remember { mutableStateOf(false) }
val updateTaskState by viewModel.updateTaskState.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val statuses by LookupsRepository.taskStatuses.collectAsState()
// Validation errors
var titleError by remember { mutableStateOf("") }
var dueDateError by remember { mutableStateOf("") }
// Handle errors for task update
updateTaskState.HandleErrors(
onRetry = { /* Retry handled in UI */ },
errorTitle = "Failed to Update Task"
)
// Handle update state changes
LaunchedEffect(updateTaskState) {
when (updateTaskState) {
is ApiResult.Success -> {
viewModel.resetUpdateTaskState()
onTaskUpdated()
}
else -> {}
}
}
fun validateForm(): Boolean {
var isValid = true
if (title.isBlank()) {
titleError = "Title is required"
isValid = false
} else {
titleError = ""
}
if (dueDate.isNullOrBlank()) {
dueDateError = "Due date is required"
isValid = false
} else {
dueDateError = ""
}
return isValid
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Task") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Required fields section
Text(
text = "Task Details",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
// Category dropdown
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) {
OutlinedTextField(
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Category *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = categories.isNotEmpty()
)
ExposedDropdownMenu(
expanded = categoryExpanded,
onDismissRequest = { categoryExpanded = false }
) {
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedCategory = category
categoryExpanded = false
}
)
}
}
}
// Frequency dropdown
ExposedDropdownMenuBox(
expanded = frequencyExpanded,
onExpandedChange = { frequencyExpanded = it }
) {
OutlinedTextField(
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Frequency *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = frequencyExpanded,
onDismissRequest = { frequencyExpanded = false }
) {
frequencies.forEach { frequency ->
DropdownMenuItem(
text = { Text(frequency.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedFrequency = frequency
frequencyExpanded = false
}
)
}
}
}
// Priority dropdown
ExposedDropdownMenuBox(
expanded = priorityExpanded,
onExpandedChange = { priorityExpanded = it }
) {
OutlinedTextField(
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Priority *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = priorityExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = priorities.isNotEmpty()
)
ExposedDropdownMenu(
expanded = priorityExpanded,
onDismissRequest = { priorityExpanded = false }
) {
priorities.forEach { priority ->
DropdownMenuItem(
text = { Text(priority.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedPriority = priority
priorityExpanded = false
}
)
}
}
}
// Status dropdown
ExposedDropdownMenuBox(
expanded = statusExpanded,
onExpandedChange = { statusExpanded = it }
) {
OutlinedTextField(
value = selectedStatus?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Status *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = statusExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = statuses.isNotEmpty()
)
ExposedDropdownMenu(
expanded = statusExpanded,
onDismissRequest = { statusExpanded = false }
) {
statuses.forEach { status ->
DropdownMenuItem(
text = { Text(status.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedStatus = status
statusExpanded = false
}
)
}
}
}
OutlinedTextField(
value = dueDate,
onValueChange = { dueDate = it },
label = { Text("Due Date (YYYY-MM-DD) *") },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError.isNotEmpty(),
supportingText = if (dueDateError.isNotEmpty()) {
{ Text(dueDateError) }
} else null,
placeholder = { Text("2025-01-31") }
)
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
prefix = { Text("$") }
)
// Error message
if (updateTaskState is ApiResult.Error) {
Text(
text = com.example.casera.util.ErrorMessageParser.parse((updateTaskState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
Button(
onClick = {
if (validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null &&
selectedStatus != null) {
viewModel.updateTask(
taskId = task.id,
request = TaskCreateRequest(
residenceId = task.residenceId,
title = title,
description = description.ifBlank { null },
categoryId = selectedCategory!!.id,
frequencyId = selectedFrequency!!.id,
priorityId = selectedPriority!!.id,
statusId = selectedStatus!!.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }?.toDoubleOrNull()
)
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null &&
selectedStatus != null
) {
if (updateTaskState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Update Task")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,201 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForgotPasswordScreen(
onNavigateBack: () -> Unit,
onNavigateToVerify: () -> Unit,
onNavigateToReset: () -> Unit = {},
viewModel: PasswordResetViewModel
) {
var email by remember { mutableStateOf("") }
val forgotPasswordState by viewModel.forgotPasswordState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for forgot password
forgotPasswordState.HandleErrors(
onRetry = {
viewModel.setEmail(email)
viewModel.requestPasswordReset(email)
},
errorTitle = "Failed to Send Reset Code"
)
// Handle automatic navigation to next step
LaunchedEffect(currentStep) {
when (currentStep) {
com.example.casera.viewmodel.PasswordResetStep.VERIFY_CODE -> onNavigateToVerify()
com.example.casera.viewmodel.PasswordResetStep.RESET_PASSWORD -> onNavigateToReset()
else -> {}
}
}
val errorMessage = when (forgotPasswordState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((forgotPasswordState as ApiResult.Error).message)
else -> ""
}
val isLoading = forgotPasswordState is ApiResult.Loading
val isSuccess = forgotPasswordState is ApiResult.Success
Scaffold(
topBar = {
TopAppBar(
title = { Text("Forgot Password") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
AuthHeader(
icon = Icons.Default.Key,
title = "Forgot Password?",
subtitle = "Enter your email address and we'll send you a code to reset your password"
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = email,
onValueChange = {
email = it
viewModel.resetForgotPasswordState()
},
label = { Text("Email Address") },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
Text(
"We'll send a 6-digit verification code to this address",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
ErrorCard(message = errorMessage)
if (isSuccess) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Check your email for a 6-digit verification code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Button(
onClick = {
viewModel.setEmail(email)
viewModel.requestPasswordReset(email)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = email.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Send, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Send Reset Code",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
TextButton(
onClick = onNavigateBack,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Remember your password? Back to Login",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}

View File

@@ -0,0 +1,321 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.theme.AppRadius
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onNavigateToResidences: () -> Unit,
onNavigateToTasks: () -> Unit,
onLogout: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val summaryState by viewModel.myResidencesState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadMyResidences()
}
// Handle errors for loading summary
summaryState.HandleErrors(
onRetry = { viewModel.loadMyResidences() },
errorTitle = "Failed to Load Summary"
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("myCrib", style = MaterialTheme.typography.headlineSmall) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
actions = {
IconButton(onClick = onLogout) {
Icon(
Icons.Default.ExitToApp,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.primary
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Personalized Greeting
Column(
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(
text = "Welcome back",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Manage your properties",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Summary Card
when (summaryState) {
is ApiResult.Success -> {
val summary = (summaryState as ApiResult.Success).data
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Gradient circular icon
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Home,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
Column {
Text(
text = "Overview",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your property stats",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
value = "${summary.residences.size}",
label = "Properties",
color = Color(0xFF3B82F6)
)
Divider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
StatItem(
value = "${summary.summary.totalTasks}",
label = "Total Tasks",
color = Color(0xFF8B5CF6)
)
Divider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
StatItem(
value = "${summary.summary.totalPending}",
label = "Pending",
color = Color(0xFFF59E0B)
)
}
}
}
}
is ApiResult.Idle, is ApiResult.Loading -> {
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
// Don't show error card, just let navigation cards show
}
else -> {}
}
// Residences Card
NavigationCard(
title = "Properties",
subtitle = "Manage your residences",
icon = Icons.Default.Home,
iconColor = Color(0xFF3B82F6),
onClick = onNavigateToResidences
)
// Tasks Card
NavigationCard(
title = "Tasks",
subtitle = "View and manage tasks",
icon = Icons.Default.CheckCircle,
iconColor = Color(0xFF10B981),
onClick = onNavigateToTasks
)
}
}
}
@Composable
private fun StatItem(
value: String,
label: String,
color: Color
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
color = color
)
}
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun NavigationCard(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
iconColor: Color,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Gradient circular icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
iconColor,
iconColor.copy(alpha = 0.7f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,210 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@Composable
fun LoginScreen(
onLoginSuccess: (com.example.casera.models.User) -> Unit,
onNavigateToRegister: () -> Unit,
onNavigateToForgotPassword: () -> Unit = {},
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val loginState by viewModel.loginState.collectAsState()
// Handle errors for login
loginState.HandleErrors(
onRetry = { viewModel.login(username, password) },
errorTitle = "Login Failed"
)
// Handle login state changes
LaunchedEffect(loginState) {
when (loginState) {
is ApiResult.Success -> {
val user = (loginState as ApiResult.Success).data.user
onLoginSuccess(user)
}
else -> {}
}
}
val errorMessage = when (loginState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
else -> ""
}
val isLoading = loginState is ApiResult.Loading
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
AuthHeader(
icon = Icons.Default.Home,
title = "myCrib",
subtitle = "Manage your properties with ease"
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username or Email") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
ErrorCard(message = errorMessage)
// Gradient button
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(MaterialTheme.shapes.medium)
.then(
if (username.isNotEmpty() && password.isNotEmpty() && !isLoading) {
Modifier.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
)
} else {
Modifier.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f))
}
),
contentAlignment = Alignment.Center
) {
Button(
onClick = {
viewModel.login(username, password)
},
modifier = Modifier.fillMaxSize(),
enabled = username.isNotEmpty() && password.isNotEmpty(),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
"Sign In",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
TextButton(
onClick = onNavigateToForgotPassword,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Forgot Password?",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
TextButton(
onClick = onNavigateToRegister,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Don't have an account? Register",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}

View File

@@ -0,0 +1,252 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.casera.navigation.*
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
import com.example.casera.storage.TokenStorage
@Composable
fun MainScreen(
onLogout: () -> Unit,
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (com.example.casera.models.TaskDetail) -> Unit,
onAddTask: () -> Unit
) {
var selectedTab by remember { mutableStateOf(0) }
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
) {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Residences") },
label = { Text("Residences") },
selected = selectedTab == 0,
onClick = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = true }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.CheckCircle, contentDescription = "Tasks") },
label = { Text("Tasks") },
selected = selectedTab == 1,
onClick = {
selectedTab = 1
navController.navigate(MainTabTasksRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Build, contentDescription = "Contractors") },
label = { Text("Contractors") },
selected = selectedTab == 2,
onClick = {
selectedTab = 2
navController.navigate(MainTabContractorsRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Description, contentDescription = "Documents") },
label = { Text("Documents") },
selected = selectedTab == 3,
onClick = {
selectedTab = 3
navController.navigate(MainTabDocumentsRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
// NavigationBarItem(
// icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
// label = { Text("Profile") },
// selected = selectedTab == 4,
// onClick = {
// selectedTab = 4
// navController.navigate(MainTabProfileRoute) {
// popUpTo(MainTabResidencesRoute) { inclusive = false }
// }
// },
// colors = NavigationBarItemDefaults.colors(
// selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
// selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
// indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
// unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
// unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
// )
// )
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = MainTabResidencesRoute,
modifier = Modifier.fillMaxSize()
) {
composable<MainTabResidencesRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ResidencesScreen(
onResidenceClick = onResidenceClick,
onAddResidence = onAddResidence,
onLogout = onLogout,
onNavigateToProfile = {
selectedTab = 3
navController.navigate(MainTabProfileRoute)
}
)
}
}
composable<MainTabTasksRoute> {
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding()
)
}
}
composable<MainTabContractorsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ContractorsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
}
)
}
}
composable<ContractorDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ContractorDetailRoute>()
Box(modifier = Modifier.fillMaxSize()) {
ContractorDetailScreen(
contractorId = route.contractorId,
onNavigateBack = {
navController.popBackStack()
}
)
}
}
composable<MainTabDocumentsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
DocumentsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
residenceId = null,
onNavigateToAddDocument = { residenceId, documentType ->
navController.navigate(
AddDocumentRoute(
residenceId = residenceId,
initialDocumentType = documentType
)
)
},
onNavigateToDocumentDetail = { documentId ->
navController.navigate(DocumentDetailRoute(documentId))
}
)
}
}
composable<AddDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDocumentRoute>()
AddDocumentScreen(
residenceId = route.residenceId,
initialDocumentType = route.initialDocumentType,
onNavigateBack = { navController.popBackStack() },
onDocumentCreated = {
navController.popBackStack()
}
)
}
composable<DocumentDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DocumentDetailRoute>()
DocumentDetailScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { documentId ->
navController.navigate(EditDocumentRoute(documentId))
}
)
}
composable<EditDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditDocumentRoute>()
EditDocumentScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() }
)
}
composable<MainTabProfileRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ProfileScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onLogout = onLogout
)
}
}
}
}
}

View File

@@ -0,0 +1,458 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.ui.components.dialogs.ThemePickerDialog
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
import com.example.casera.storage.TokenStorage
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.subscription.UpgradePromptDialog
import androidx.compose.runtime.getValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
onLogout: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var successMessage by remember { mutableStateOf("") }
var isLoadingUser by remember { mutableStateOf(true) }
var showThemePicker by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
val updateState by viewModel.updateProfileState.collectAsState()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val currentSubscription by SubscriptionCache.currentSubscription
// Handle errors for profile update
updateState.HandleErrors(
onRetry = {
viewModel.updateProfile(
firstName = firstName.ifBlank { null },
lastName = lastName.ifBlank { null },
email = email
)
},
errorTitle = "Failed to Update Profile"
)
// Load current user data
LaunchedEffect(Unit) {
val token = TokenStorage.getToken()
if (token != null) {
val authApi = com.example.casera.network.AuthApi()
when (val result = authApi.getCurrentUser(token)) {
is ApiResult.Success -> {
firstName = result.data.firstName ?: ""
lastName = result.data.lastName ?: ""
email = result.data.email
isLoadingUser = false
}
else -> {
errorMessage = "Failed to load user data"
isLoadingUser = false
}
}
} else {
errorMessage = "Not authenticated"
isLoadingUser = false
}
}
// TODO: Re-enable profile update functionality
/*
LaunchedEffect(updateState) {
when (updateState) {
is ApiResult.Success -> {
successMessage = "Profile updated successfully"
isLoading = false
errorMessage = ""
}
is ApiResult.Error -> {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((updateState as ApiResult.Error).message)
isLoading = false
successMessage = ""
}
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
successMessage = ""
}
is ApiResult.Idle -> {
// Do nothing - initial state, no loading indicator needed
}
else -> {}
}
}
*/
Scaffold(
topBar = {
TopAppBar(
title = { Text("Profile", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onLogout) {
Icon(Icons.Default.Logout, contentDescription = "Logout")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
if (isLoadingUser) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 96.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
// Profile Icon
Icon(
Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"Update Your Profile",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Theme Selector Section
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { showThemePicker = true },
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = currentTheme.displayName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Icon(
imageVector = Icons.Default.Palette,
contentDescription = "Change theme",
tint = MaterialTheme.colorScheme.primary
)
}
}
// Subscription Section - Only show if limitations are enabled
if (currentSubscription?.limitationsEnabled == true) {
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Subscription",
tint = if (SubscriptionHelper.currentTier == "pro") MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
text = if (SubscriptionHelper.currentTier == "pro") "Pro Plan" else "Free Plan",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (SubscriptionHelper.currentTier == "pro" && currentSubscription?.expiresAt != null) {
"Active until ${currentSubscription?.expiresAt}"
} else {
"Limited features"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (SubscriptionHelper.currentTier != "pro") {
Button(
onClick = { showUpgradePrompt = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = null
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text("Upgrade to Pro", fontWeight = FontWeight.SemiBold)
}
} else {
Text(
text = "Manage your subscription in the Google Play Store",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
}
Text(
"Profile Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.align(Alignment.Start)
)
OutlinedTextField(
value = firstName,
onValueChange = { firstName = it },
label = { Text("First Name") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = lastName,
onValueChange = { lastName = it },
label = { Text("Last Name") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
if (errorMessage.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(AppRadius.md)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
if (successMessage.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
successMessage,
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (email.isNotEmpty()) {
// viewModel.updateProfile not available yet
errorMessage = "Profile update coming soon"
} else {
errorMessage = "Email is required"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = email.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Save, contentDescription = null)
Text(
"Save Changes",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
// Theme Picker Dialog
if (showThemePicker) {
ThemePickerDialog(
currentTheme = currentTheme,
onThemeSelected = { theme ->
ThemeManager.setTheme(theme)
showThemePicker = false
},
onDismiss = { showThemePicker = false }
)
}
// Upgrade Prompt Dialog
if (showUpgradePrompt) {
UpgradePromptDialog(
triggerKey = "profile_upgrade",
onUpgrade = {
// Handle upgrade action - on Android this would open Play Store
// For now, just close the dialog
showUpgradePrompt = false
},
onDismiss = { showUpgradePrompt = false }
)
}
}
}

View File

@@ -0,0 +1,181 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
onRegisterSuccess: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val createState by viewModel.registerState.collectAsState()
// Handle errors for registration
createState.HandleErrors(
onRetry = { viewModel.register(username, email, password) },
errorTitle = "Registration Failed"
)
LaunchedEffect(createState) {
when (createState) {
is ApiResult.Success -> {
viewModel.resetRegisterState()
onRegisterSuccess()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Account", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.PersonAdd,
title = "Join myCrib",
subtitle = "Start managing your properties today"
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text("Confirm Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
ErrorCard(message = errorMessage)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
when {
password != confirmPassword -> {
errorMessage = "Passwords do not match"
}
else -> {
isLoading = true
errorMessage = ""
viewModel.register(username, email, password)
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = username.isNotEmpty() && email.isNotEmpty() &&
password.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Create Account",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,264 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.auth.RequirementItem
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResetPasswordScreen(
onPasswordResetSuccess: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
viewModel: PasswordResetViewModel
) {
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var newPasswordVisible by remember { mutableStateOf(false) }
var confirmPasswordVisible by remember { mutableStateOf(false) }
val resetPasswordState by viewModel.resetPasswordState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for password reset
resetPasswordState.HandleErrors(
onRetry = { viewModel.resetPassword(newPassword, confirmPassword) },
errorTitle = "Password Reset Failed"
)
val errorMessage = when (resetPasswordState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((resetPasswordState as ApiResult.Error).message)
else -> ""
}
val isLoading = resetPasswordState is ApiResult.Loading
val isSuccess = currentStep == com.example.casera.viewmodel.PasswordResetStep.SUCCESS
// Password validation
val hasLetter = newPassword.any { it.isLetter() }
val hasNumber = newPassword.any { it.isDigit() }
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
Scaffold(
topBar = {
TopAppBar(
title = { Text("Reset Password") },
navigationIcon = {
onNavigateBack?.let { callback ->
IconButton(onClick = callback) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
if (isSuccess) {
// Success State
AuthHeader(
icon = Icons.Default.CheckCircle,
title = "Success!",
subtitle = "Your password has been reset successfully"
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Text(
"You can now log in with your new password",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
textAlign = TextAlign.Center
)
}
Button(
onClick = onPasswordResetSuccess,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
"Return to Login",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
} else {
// Reset Password Form
AuthHeader(
icon = Icons.Default.LockReset,
title = "Set New Password",
subtitle = "Create a strong password to secure your account"
)
// Password Requirements
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Password Requirements",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
RequirementItem(
"At least 8 characters",
newPassword.length >= 8
)
RequirementItem(
"Contains letters",
hasLetter
)
RequirementItem(
"Contains numbers",
hasNumber
)
RequirementItem(
"Passwords match",
passwordsMatch
)
}
}
OutlinedTextField(
value = newPassword,
onValueChange = {
newPassword = it
viewModel.resetResetPasswordState()
},
label = { Text("New Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) {
Icon(
if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (newPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (newPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
viewModel.resetResetPasswordState()
},
label = { Text("Confirm Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
ErrorCard(message = errorMessage)
Button(
onClick = {
viewModel.resetPassword(newPassword, confirmPassword)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = isFormValid && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.LockReset, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Reset Password",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,750 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddNewTaskDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.CompleteTaskDialog
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.ManageUsersDialog
import com.example.casera.ui.components.common.InfoCard
import com.example.casera.ui.components.residence.PropertyDetailItem
import com.example.casera.ui.components.residence.DetailRow
import com.example.casera.ui.components.task.TaskCard
import com.example.casera.ui.components.task.DynamicTaskKanbanView
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.models.Residence
import com.example.casera.models.TaskDetail
import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.cache.DataCache
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidenceDetailScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (TaskDetail) -> Unit,
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
val uncancelTaskState by residenceViewModel.uncancelTaskState.collectAsState()
val generateReportState by residenceViewModel.generateReportState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var showManageUsersDialog by remember { mutableStateOf(false) }
var showReportSnackbar by remember { mutableStateOf(false) }
var reportMessage by remember { mutableStateOf("") }
var showReportConfirmation by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
var showCancelTaskConfirmation by remember { mutableStateOf(false) }
var showArchiveTaskConfirmation by remember { mutableStateOf(false) }
var taskToCancel by remember { mutableStateOf<TaskDetail?>(null) }
var taskToArchive by remember { mutableStateOf<TaskDetail?>(null) }
val deleteState by residenceViewModel.deleteResidenceState.collectAsState()
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Get current user for ownership checks
val currentUser by DataCache.currentUser.collectAsState()
// Check if tasks are blocked (limit=0) - this hides the FAB
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
// Get current count for checking when adding
val currentTaskCount = (tasksState as? ApiResult.Success)?.data?.columns?.sumOf { it.tasks.size } ?: 0
// Helper function to check if user can add a task
fun canAddTask(): Pair<Boolean, String?> {
val check = SubscriptionHelper.canAddTask(currentTaskCount)
return Pair(check.allowed, check.triggerKey)
}
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
residenceViewModel.loadResidenceTasks(residenceId)
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
is ApiResult.Success -> {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
LaunchedEffect(taskAddNewTaskState) {
when (taskAddNewTaskState) {
is ApiResult.Success -> {
showNewTaskDialog = false
taskViewModel.resetAddTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle cancel task success
LaunchedEffect(cancelTaskState) {
when (cancelTaskState) {
is ApiResult.Success -> {
residenceViewModel.resetCancelTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle uncancel task success
LaunchedEffect(uncancelTaskState) {
when (uncancelTaskState) {
is ApiResult.Success -> {
residenceViewModel.resetUncancelTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle generate report state
// Handle errors for generate report
generateReportState.HandleErrors(
onRetry = { residenceViewModel.generateTasksReport(residenceId) },
errorTitle = "Failed to Generate Report"
)
LaunchedEffect(generateReportState) {
when (generateReportState) {
is ApiResult.Success -> {
val response = (generateReportState as ApiResult.Success).data
reportMessage = response.message
showReportSnackbar = true
residenceViewModel.resetGenerateReportState()
}
else -> {}
}
}
// Handle errors for delete residence
deleteState.HandleErrors(
onRetry = { residenceViewModel.deleteResidence(residenceId) },
errorTitle = "Failed to Delete Property"
)
// Handle delete residence state
LaunchedEffect(deleteState) {
when (deleteState) {
is ApiResult.Success -> {
residenceViewModel.resetDeleteResidenceState()
onNavigateBack()
}
else -> {}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title,
onDismiss = {
taskCompletionViewModel.resetCreateState()
},
onComplete = { request, images ->
if (images.isNotEmpty()) {
taskCompletionViewModel.createTaskCompletionWithImages(
request = request,
images = images
)
} else {
taskCompletionViewModel.createTaskCompletion(request)
}
}
)
}
if (showNewTaskDialog) {
AddNewTaskDialog(
residenceId,
onDismiss = {
showNewTaskDialog = false
}, onCreate = { request ->
showNewTaskDialog = false
taskViewModel.createNewTask(request)
})
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
if (showManageUsersDialog && residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
ManageUsersDialog(
residenceId = residence.id,
residenceName = residence.name,
isPrimaryOwner = residence.ownerId == currentUser?.id,
onDismiss = {
showManageUsersDialog = false
},
onUserRemoved = {
// Reload residence to update user count
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
}
)
}
if (showReportConfirmation) {
AlertDialog(
onDismissRequest = { showReportConfirmation = false },
title = { Text("Generate Report") },
text = { Text("This will generate and email a maintenance report for this property. Do you want to continue?") },
confirmButton = {
Button(
onClick = {
showReportConfirmation = false
residenceViewModel.generateTasksReport(residenceId)
}
) {
Text("Generate")
}
},
dismissButton = {
TextButton(onClick = { showReportConfirmation = false }) {
Text("Cancel")
}
}
)
}
if (showDeleteConfirmation && residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
title = { Text("Delete Residence") },
text = { Text("Are you sure you want to delete ${residence.name}? This action cannot be undone.") },
confirmButton = {
Button(
onClick = {
showDeleteConfirmation = false
residenceViewModel.deleteResidence(residenceId)
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text("Cancel")
}
}
)
}
if (showCancelTaskConfirmation && taskToCancel != null) {
AlertDialog(
onDismissRequest = {
showCancelTaskConfirmation = false
taskToCancel = null
},
title = { Text("Cancel Task") },
text = { Text("Are you sure you want to cancel \"${taskToCancel!!.title}\"? This action cannot be undone.") },
confirmButton = {
Button(
onClick = {
showCancelTaskConfirmation = false
residenceViewModel.cancelTask(taskToCancel!!.id)
taskToCancel = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Cancel Task")
}
},
dismissButton = {
TextButton(onClick = {
showCancelTaskConfirmation = false
taskToCancel = null
}) {
Text("Dismiss")
}
}
)
}
if (showArchiveTaskConfirmation && taskToArchive != null) {
AlertDialog(
onDismissRequest = {
showArchiveTaskConfirmation = false
taskToArchive = null
},
title = { Text("Archive Task") },
text = { Text("Are you sure you want to archive \"${taskToArchive!!.title}\"? You can unarchive it later from archived tasks.") },
confirmButton = {
Button(
onClick = {
showArchiveTaskConfirmation = false
taskViewModel.archiveTask(taskToArchive!!.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
taskToArchive = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Archive")
}
},
dismissButton = {
TextButton(onClick = {
showArchiveTaskConfirmation = false
taskToArchive = null
}) {
Text("Dismiss")
}
}
)
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(showReportSnackbar) {
if (showReportSnackbar) {
snackbarHostState.showSnackbar(reportMessage)
showReportSnackbar = false
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Property Details", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
// Edit button - only show when residence is loaded
if (residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
// Generate Report button
IconButton(
onClick = {
showReportConfirmation = true
},
enabled = generateReportState !is ApiResult.Loading
) {
if (generateReportState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Description, contentDescription = "Generate Report")
}
}
// Manage Users button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(onClick = {
showManageUsersDialog = true
}) {
Icon(Icons.Default.People, contentDescription = "Manage Users")
}
}
IconButton(onClick = {
onNavigateToEditResidence(residence)
}) {
Icon(Icons.Default.Edit, contentDescription = "Edit Residence")
}
// Delete button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(onClick = {
showDeleteConfirmation = true
}) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete Residence",
tint = MaterialTheme.colorScheme.error
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Don't show FAB if tasks are blocked (limit=0)
if (!isTasksBlocked.allowed) {
FloatingActionButton(
onClick = {
val (allowed, triggerKey) = canAddTask()
if (allowed) {
showNewTaskDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add Task")
}
}
}
) { paddingValues ->
ApiResultHandler(
state = residenceState,
onRetry = {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
},
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Property",
loadingContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = "Loading residence...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
) { residence ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Property Header Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = residence.name,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
// Address Card
if (residence.streetAddress != null || residence.city != null ||
residence.stateProvince != null || residence.postalCode != null ||
residence.country != null) {
item {
InfoCard(
icon = Icons.Default.LocationOn,
title = "Address"
) {
if (residence.streetAddress != null) {
Text(text = residence.streetAddress)
}
if (residence.apartmentUnit != null) {
Text(text = "Unit: ${residence.apartmentUnit}")
}
if (residence.city != null || residence.stateProvince != null || residence.postalCode != null) {
Text(text = "${residence.city ?: ""}, ${residence.stateProvince ?: ""} ${residence.postalCode ?: ""}")
}
if (residence.country != null) {
Text(text = residence.country)
}
}
}
}
// Property Details Card
if (residence.bedrooms != null || residence.bathrooms != null ||
residence.squareFootage != null || residence.yearBuilt != null) {
item {
InfoCard(
icon = Icons.Default.Info,
title = "Property Details"
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
residence.bedrooms?.let {
PropertyDetailItem(Icons.Default.Bed, "$it", "Bedrooms")
}
residence.bathrooms?.let {
PropertyDetailItem(Icons.Default.Bathroom, "$it", "Bathrooms")
}
}
Spacer(modifier = Modifier.height(12.dp))
residence.squareFootage?.let {
DetailRow(Icons.Default.SquareFoot, "Square Footage", "$it sq ft")
}
residence.lotSize?.let {
DetailRow(Icons.Default.Landscape, "Lot Size", "$it acres")
}
residence.yearBuilt?.let {
DetailRow(Icons.Default.CalendarToday, "Year Built", "$it")
}
}
}
}
// Description Card
if (residence.description != null && !residence.description.isEmpty()) {
item {
InfoCard(
icon = Icons.Default.Description,
title = "Description"
) {
Text(
text = residence.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Purchase Information
if (residence.purchaseDate != null || residence.purchasePrice != null) {
item {
InfoCard(
icon = Icons.Default.AttachMoney,
title = "Purchase Information"
) {
residence.purchaseDate?.let {
DetailRow(Icons.Default.Event, "Purchase Date", it)
}
residence.purchasePrice?.let {
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")
}
}
}
}
// Tasks Section Header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tasks",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Error loading tasks: ${com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val allTasksEmpty = taskData.columns.all { it.tasks.isEmpty() }
if (allTasksEmpty) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No tasks yet",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
"Add a task to get started",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
) {
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
selectedTask = task
showCompleteDialog = true
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
taskToCancel = task
showCancelTaskConfirmation = true
},
onUncancelTask = { task ->
residenceViewModel.uncancelTask(task.id)
},
onMarkInProgress = { task ->
taskViewModel.markInProgress(task.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
},
onArchiveTask = { task ->
taskToArchive = task
showArchiveTaskConfirmation = true
},
onUnarchiveTask = { task ->
taskViewModel.unarchiveTask(task.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
}
)
}
}
}
}
else -> {}
}
}
}
}
}

View File

@@ -0,0 +1,348 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.ResidenceType
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidenceFormScreen(
existingResidence: Residence? = null,
onNavigateBack: () -> Unit,
onSuccess: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val isEditMode = existingResidence != null
// Form state
var name by remember { mutableStateOf(existingResidence?.name ?: "") }
var propertyType by remember { mutableStateOf<ResidenceType?>(null) }
var streetAddress by remember { mutableStateOf(existingResidence?.streetAddress ?: "") }
var apartmentUnit by remember { mutableStateOf(existingResidence?.apartmentUnit ?: "") }
var city by remember { mutableStateOf(existingResidence?.city ?: "") }
var stateProvince by remember { mutableStateOf(existingResidence?.stateProvince ?: "") }
var postalCode by remember { mutableStateOf(existingResidence?.postalCode ?: "") }
var country by remember { mutableStateOf(existingResidence?.country ?: "USA") }
var bedrooms by remember { mutableStateOf(existingResidence?.bedrooms?.toString() ?: "") }
var bathrooms by remember { mutableStateOf(existingResidence?.bathrooms?.toString() ?: "") }
var squareFootage by remember { mutableStateOf(existingResidence?.squareFootage?.toString() ?: "") }
var lotSize by remember { mutableStateOf(existingResidence?.lotSize?.toString() ?: "") }
var yearBuilt by remember { mutableStateOf(existingResidence?.yearBuilt?.toString() ?: "") }
var description by remember { mutableStateOf(existingResidence?.description ?: "") }
var isPrimary by remember { mutableStateOf(existingResidence?.isPrimary ?: false) }
var expanded by remember { mutableStateOf(false) }
val operationState by if (isEditMode) {
viewModel.updateResidenceState.collectAsState()
} else {
viewModel.createResidenceState.collectAsState()
}
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
// Validation errors
var nameError by remember { mutableStateOf("") }
// Handle operation state changes
LaunchedEffect(operationState) {
when (operationState) {
is ApiResult.Success -> {
if (isEditMode) {
viewModel.resetUpdateState()
} else {
viewModel.resetCreateState()
}
onSuccess()
}
else -> {}
}
}
// Set default/existing property type when types are loaded
LaunchedEffect(propertyTypes, existingResidence) {
if (propertyTypes.isNotEmpty() && propertyType == null) {
propertyType = if (isEditMode && existingResidence != null && existingResidence.propertyTypeId != null) {
propertyTypes.find { it.id == existingResidence.propertyTypeId }
} else if (!isEditMode && propertyTypes.isNotEmpty()) {
propertyTypes.first()
} else {
null
}
}
}
fun validateForm(): Boolean {
var isValid = true
if (name.isBlank()) {
nameError = "Name is required"
isValid = false
} else {
nameError = ""
}
return isValid
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditMode) "Edit Residence" else "Add Residence") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Basic Information section
Text(
text = "Property Details",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Property Name *") },
modifier = Modifier.fillMaxWidth(),
isError = nameError.isNotEmpty(),
supportingText = if (nameError.isNotEmpty()) {
{ Text(nameError, color = MaterialTheme.colorScheme.error) }
} else {
{ Text("Required", color = MaterialTheme.colorScheme.error) }
}
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Property Type") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = propertyTypes.isNotEmpty()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
propertyTypes.forEach { type ->
DropdownMenuItem(
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
onClick = {
propertyType = type
expanded = false
}
)
}
}
}
// Address section
Text(
text = "Address",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = streetAddress,
onValueChange = { streetAddress = it },
label = { Text("Street Address") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = apartmentUnit,
onValueChange = { apartmentUnit = it },
label = { Text("Apartment/Unit #") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text("City") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = stateProvince,
onValueChange = { stateProvince = it },
label = { Text("State/Province") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = postalCode,
onValueChange = { postalCode = it },
label = { Text("Postal Code") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = country,
onValueChange = { country = it },
label = { Text("Country") },
modifier = Modifier.fillMaxWidth()
)
// Optional fields section
Divider()
Text(
text = "Optional Details",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = bedrooms,
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
label = { Text("Bedrooms") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = bathrooms,
onValueChange = { bathrooms = it },
label = { Text("Bathrooms") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = squareFootage,
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
label = { Text("Square Footage") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = lotSize,
onValueChange = { lotSize = it },
label = { Text("Lot Size (acres)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = yearBuilt,
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
label = { Text("Year Built") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Primary Residence")
Switch(
checked = isPrimary,
onCheckedChange = { isPrimary = it }
)
}
// Error message
if (operationState is ApiResult.Error) {
Text(
text = com.example.casera.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
Button(
onClick = {
if (validateForm()) {
val request = ResidenceCreateRequest(
name = name,
propertyTypeId = propertyType?.id,
streetAddress = streetAddress.ifBlank { null },
apartmentUnit = apartmentUnit.ifBlank { null },
city = city.ifBlank { null },
stateProvince = stateProvince.ifBlank { null },
postalCode = postalCode.ifBlank { null },
country = country.ifBlank { null },
bedrooms = bedrooms.toIntOrNull(),
bathrooms = bathrooms.toDoubleOrNull(),
squareFootage = squareFootage.toIntOrNull(),
lotSize = lotSize.toDoubleOrNull(),
yearBuilt = yearBuilt.toIntOrNull(),
description = description.ifBlank { null },
isPrimary = isPrimary
)
if (isEditMode && existingResidence != null) {
viewModel.updateResidence(existingResidence.id, request)
} else {
viewModel.createResidence(request)
}
}
},
modifier = Modifier.fillMaxWidth(),
enabled = validateForm()
) {
if (operationState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(if (isEditMode) "Update Residence" else "Create Residence")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,531 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.JoinResidenceDialog
import com.example.casera.ui.components.common.StatItem
import com.example.casera.ui.components.residence.TaskStatChip
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidencesScreen(
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onLogout: () -> Unit,
onNavigateToProfile: () -> Unit = {},
shouldRefresh: Boolean = false,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val myResidencesState by viewModel.myResidencesState.collectAsState()
var showJoinDialog by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Check if screen is blocked (limit=0) - this hides the FAB
val isBlocked = SubscriptionHelper.isResidencesBlocked()
// Get current count for checking when adding
val currentCount = (myResidencesState as? ApiResult.Success)?.data?.residences?.size ?: 0
// Helper function to check if user can add a property
fun canAddProperty(): Pair<Boolean, String?> {
val check = SubscriptionHelper.canAddProperty(currentCount)
return Pair(check.allowed, check.triggerKey)
}
LaunchedEffect(Unit) {
viewModel.loadMyResidences()
}
// Refresh when shouldRefresh flag changes
LaunchedEffect(shouldRefresh) {
if (shouldRefresh) {
viewModel.loadMyResidences(forceRefresh = true)
}
}
// Handle refresh state
LaunchedEffect(myResidencesState) {
if (myResidencesState !is ApiResult.Loading) {
isRefreshing = false
}
}
if (showJoinDialog) {
JoinResidenceDialog(
onDismiss = {
showJoinDialog = false
},
onJoined = {
// Reload residences after joining
viewModel.loadMyResidences()
}
)
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"My Properties",
fontWeight = FontWeight.Bold
)
},
actions = {
// Only show Join button if not blocked (limit>0)
if (!isBlocked.allowed) {
IconButton(onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
}) {
Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code")
}
}
IconButton(onClick = onNavigateToProfile) {
Icon(Icons.Default.AccountCircle, contentDescription = "Profile")
}
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Logout")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Only show FAB when there are properties and NOT blocked (limit>0)
val hasResidences = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
if (hasResidences && !isBlocked.allowed) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 8.dp,
pressedElevation = 12.dp
)
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Property",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
},
floatingActionButtonPosition = FabPosition.End
) { paddingValues ->
ApiResultHandler(
state = myResidencesState,
onRetry = { viewModel.loadMyResidences() },
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Properties"
) { response ->
if (response.residences.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No properties yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Add your first property to get started!",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// Only show Add Property button if not blocked (limit>0)
if (!isBlocked.allowed) {
Button(
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Text(
"Add Property",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.GroupAdd, contentDescription = null)
Text(
"Join with Code",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
} else {
// Show upgrade prompt when limit=0
Button(
onClick = {
upgradeTriggerKey = isBlocked.triggerKey
showUpgradePrompt = true
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Star, contentDescription = null)
Text(
"Upgrade to Add",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadMyResidences()
},
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 96.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Summary Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Dashboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.Home,
value = "${response.summary.totalResidences}",
label = "Properties"
)
StatItem(
icon = Icons.Default.Assignment,
value = "${response.summary.totalTasks}",
label = "Total Tasks"
)
}
HorizontalDivider(
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.CalendarToday,
value = "${response.summary.tasksDueNextWeek}",
label = "Due This Week"
)
StatItem(
icon = Icons.Default.Event,
value = "${response.summary.tasksDueNextMonth}",
label = "Next 30 Days"
)
}
}
}
}
// Properties Header
item {
Text(
text = "Your Properties",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp)
)
}
// Residences
items(response.residences) { residence ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onResidenceClick(residence.id) },
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Gradient circular house icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Home,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = residence.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${residence.city}, ${residence.stateProvince}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
// Fully dynamic task summary from API - show first 3 categories
val displayCategories = residence.taskSummary.categories.take(3)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
displayCategories.forEach { category ->
TaskStatChip(
icon = getIconForCategory(category.icons.android),
value = "${category.count}",
label = category.displayName,
color = parseHexColor(category.color)
)
}
}
}
}
}
}
}
}
}
}
}
/**
* Map Android icon name from backend to Material Icon
*/
private fun getIconForCategory(iconName: String) = when (iconName) {
"Warning" -> Icons.Default.Warning
"CalendarToday" -> Icons.Default.CalendarToday
"PlayCircle" -> Icons.Default.PlayCircle
"Inbox" -> Icons.Default.Inbox
"CheckCircle" -> Icons.Default.CheckCircle
"Archive" -> Icons.Default.Archive
else -> Icons.Default.Circle
}
/**
* Parse hex color string to Compose Color
* Works in commonMain without android dependencies
*/
private fun parseHexColor(hex: String): Color {
return try {
val cleanHex = hex.removePrefix("#")
val colorInt = cleanHex.toLong(16)
val alpha = if (cleanHex.length == 8) {
(colorInt shr 24 and 0xFF) / 255f
} else {
1f
}
val red = ((colorInt shr 16) and 0xFF) / 255f
val green = ((colorInt shr 8) and 0xFF) / 255f
val blue = (colorInt and 0xFF) / 255f
Color(red, green, blue, alpha)
} catch (e: Exception) {
Color.Gray
}
}

View File

@@ -0,0 +1,285 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.CompleteTaskDialog
import com.example.casera.ui.components.ErrorDialog
import com.example.casera.ui.components.task.TaskCard
import com.example.casera.ui.components.task.TaskPill
import com.example.casera.ui.utils.getIconFromName
import com.example.casera.ui.utils.hexToColor
import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksScreen(
onNavigateBack: () -> Unit,
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
var expandedColumns by remember { mutableStateOf(setOf<String>()) }
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<com.example.casera.models.TaskDetail?>(null) }
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
// Show error dialog when tasks fail to load
LaunchedEffect(tasksState) {
if (tasksState is ApiResult.Error) {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)
showErrorDialog = true
}
}
LaunchedEffect(Unit) {
viewModel.loadTasks()
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
is ApiResult.Success -> {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
viewModel.loadTasks()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("All Tasks") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
},
// No FAB on Tasks screen - tasks are added from within residences
) { paddingValues ->
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
Text(
"Tasks are created from your properties.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Go to Residences tab to add a property, then add tasks to it!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 16.dp,
bottom = paddingValues.calculateBottomPadding() + 16.dp,
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Task summary pills - dynamically generated from all columns
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
taskData.columns.forEach { column ->
TaskPill(
count = column.count,
label = column.displayName,
color = hexToColor(column.color)
)
}
}
}
// Dynamically render all columns
taskData.columns.forEachIndexed { index, column ->
if (column.tasks.isNotEmpty()) {
// First column (index 0) expanded by default, others collapsible
if (index == 0) {
// First column - always expanded, show tasks directly
item {
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
items(column.tasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = { },
onCancelClick = { },
onUncancelClick = { }
)
}
} else {
// Other columns - collapsible
val isExpanded = expandedColumns.contains(column.name)
item {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
expandedColumns = if (isExpanded) {
expandedColumns - column.name
} else {
expandedColumns + column.name
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(
getIconFromName(column.icons["android"] ?: "List"),
contentDescription = null,
tint = hexToColor(column.color)
)
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium
)
}
Icon(
if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
}
}
if (isExpanded) {
items(column.tasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = { },
onCancelClick = { },
onUncancelClick = { }
)
}
}
}
}
}
}
}
}
else -> {}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title,
onDismiss = {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
},
onComplete = { request, images ->
if (images.isNotEmpty()) {
taskCompletionViewModel.createTaskCompletionWithImages(
request = request,
images = images
)
} else {
taskCompletionViewModel.createTaskCompletion(request)
}
}
)
}
// Show error dialog when network call fails
if (showErrorDialog) {
ErrorDialog(
message = errorMessage,
onRetry = {
showErrorDialog = false
viewModel.loadTasks()
},
onDismiss = {
showErrorDialog = false
}
)
}
}

View File

@@ -0,0 +1,205 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyEmailScreen(
onVerifySuccess: () -> Unit,
onLogout: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var code by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val verifyState by viewModel.verifyEmailState.collectAsState()
// Handle errors for email verification
verifyState.HandleErrors(
onRetry = { viewModel.verifyEmail(code) },
errorTitle = "Verification Failed"
)
LaunchedEffect(verifyState) {
when (verifyState) {
is ApiResult.Success -> {
viewModel.resetVerifyEmailState()
onVerifySuccess()
}
is ApiResult.Error -> {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((verifyState as ApiResult.Error).message)
isLoading = false
}
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
}
is ApiResult.Idle -> {
// Do nothing - initial state, no loading indicator needed
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verify Email", fontWeight = FontWeight.SemiBold) },
actions = {
TextButton(onClick = onLogout) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Logout,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Text("Logout")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = "Verify Your Email",
subtitle = "You must verify your email address to continue"
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Email verification is required. Check your inbox for a 6-digit code.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
}
}
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
}
},
label = { Text("Verification Code") },
leadingIcon = {
Icon(Icons.Default.Pin, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = { Text("000000") }
)
if (errorMessage.isNotEmpty()) {
ErrorCard(
message = errorMessage
)
}
Button(
onClick = {
if (code.length == 6) {
isLoading = true
viewModel.verifyEmail(code)
} else {
errorMessage = "Please enter a valid 6-digit code"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading && code.length == 6
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Text(
"Verify Email",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Didn't receive the code? Check your spam folder or contact support.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -0,0 +1,254 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyResetCodeScreen(
onNavigateBack: () -> Unit,
onNavigateToReset: () -> Unit,
viewModel: PasswordResetViewModel
) {
var code by remember { mutableStateOf("") }
val email by viewModel.email.collectAsState()
val verifyCodeState by viewModel.verifyCodeState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for code verification
verifyCodeState.HandleErrors(
onRetry = { viewModel.verifyResetCode(email, code) },
errorTitle = "Code Verification Failed"
)
// Handle automatic navigation to next step
LaunchedEffect(currentStep) {
if (currentStep == com.example.casera.viewmodel.PasswordResetStep.RESET_PASSWORD) {
onNavigateToReset()
}
}
val errorMessage = when (verifyCodeState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((verifyCodeState as ApiResult.Error).message)
else -> ""
}
val isLoading = verifyCodeState is ApiResult.Loading
val isSuccess = verifyCodeState is ApiResult.Success
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verify Code") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = "Check Your Email",
subtitle = "We sent a 6-digit code to"
)
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Code expires in 15 minutes",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
viewModel.resetVerifyCodeState()
}
},
label = { Text("Verification Code") },
placeholder = { Text("000000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading,
textStyle = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
)
Text(
"Enter the 6-digit code from your email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
ErrorCard(message = errorMessage)
if (isSuccess) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Code verified! Now set your new password",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Button(
onClick = {
viewModel.verifyResetCode(email, code)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = code.length == 6 && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Verify Code",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Didn't receive the code?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = {
code = ""
viewModel.resetVerifyCodeState()
viewModel.moveToPreviousStep()
onNavigateBack()
}) {
Text(
"Send New Code",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
Text(
"Check your spam folder if you don't see it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
package com.example.casera.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
@Composable
fun FeatureComparisonDialog(
onDismiss: () -> Unit,
onUpgrade: () -> Unit
) {
val subscriptionCache = SubscriptionCache
val featureBenefits = subscriptionCache.featureBenefits.value
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
.padding(AppSpacing.md),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Choose Your Plan",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
Text(
"Upgrade to Pro for unlimited access",
modifier = Modifier.padding(horizontal = AppSpacing.lg),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(AppSpacing.lg))
// Comparison Table
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
// Header Row
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Feature",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
"Free",
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Pro",
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
}
}
HorizontalDivider()
// Feature Rows
if (featureBenefits.isNotEmpty()) {
featureBenefits.forEach { benefit ->
ComparisonRow(
featureName = benefit.featureName,
freeText = benefit.freeTier,
proText = benefit.proTier
)
HorizontalDivider()
}
} else {
// Default features if no data loaded
ComparisonRow("Properties", "1 property", "Unlimited")
HorizontalDivider()
ComparisonRow("Tasks", "10 tasks", "Unlimited")
HorizontalDivider()
ComparisonRow("Contractors", "Not available", "Unlimited")
HorizontalDivider()
ComparisonRow("Documents", "Not available", "Unlimited")
HorizontalDivider()
}
}
// Upgrade Button
Button(
onClick = {
onUpgrade()
onDismiss()
},
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
shape = MaterialTheme.shapes.medium
) {
Text("Upgrade to Pro", fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun ComparisonRow(
featureName: String,
freeText: String,
proText: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
featureName,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
Text(
freeText,
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Text(
proText,
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
}
}

View File

@@ -0,0 +1,362 @@
package com.example.casera.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
/**
* Full inline paywall screen for upgrade prompts.
* Shows feature benefits, subscription products with pricing, and action buttons.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpgradeFeatureScreen(
triggerKey: String,
icon: ImageVector,
onNavigateBack: () -> Unit
) {
var showFeatureComparison by remember { mutableStateOf(false) }
var isProcessing by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var showSuccessAlert by remember { mutableStateOf(false) }
// Look up trigger data from cache
val triggerData by remember { derivedStateOf {
SubscriptionCache.upgradeTriggers.value[triggerKey]
} }
// Fallback values if trigger not found
val title = triggerData?.title ?: "Upgrade Required"
val message = triggerData?.message ?: "This feature is available with a Pro subscription."
val buttonText = triggerData?.buttonText ?: "Upgrade to Pro"
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(AppSpacing.xl))
// Feature Icon (star gradient like iOS)
Icon(
imageVector = Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.height(AppSpacing.lg))
// Title
Text(
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
)
Spacer(Modifier.height(AppSpacing.md))
// Description
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
)
Spacer(Modifier.height(AppSpacing.xl))
// Pro Features Preview Card
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
FeatureRow(Icons.Default.Home, "Unlimited properties")
FeatureRow(Icons.Default.CheckCircle, "Unlimited tasks")
FeatureRow(Icons.Default.People, "Contractor management")
FeatureRow(Icons.Default.Description, "Document & warranty storage")
}
}
Spacer(Modifier.height(AppSpacing.xl))
// Subscription Products Section
// Note: On Android, BillingManager provides real pricing
// This is a placeholder showing static options
SubscriptionProductsSection(
isProcessing = isProcessing,
onProductSelected = { productId ->
// Trigger purchase flow
// On Android, this connects to BillingManager
isProcessing = true
errorMessage = null
// Purchase will be handled by platform-specific code
},
onRetryLoad = {
// Retry loading products
}
)
// Error Message
errorMessage?.let { error ->
Spacer(Modifier.height(AppSpacing.md))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
) {
Row(
modifier = Modifier.padding(AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Spacer(Modifier.height(AppSpacing.lg))
// Compare Plans
TextButton(onClick = { showFeatureComparison = true }) {
Text("Compare Free vs Pro")
}
// Restore Purchases
TextButton(onClick = {
// Trigger restore purchases
isProcessing = true
errorMessage = null
}) {
Text(
"Restore Purchases",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(AppSpacing.xl * 2))
}
if (showFeatureComparison) {
FeatureComparisonDialog(
onDismiss = { showFeatureComparison = false },
onUpgrade = {
// Trigger upgrade
showFeatureComparison = false
}
)
}
if (showSuccessAlert) {
AlertDialog(
onDismissRequest = { showSuccessAlert = false },
title = { Text("Subscription Active") },
text = { Text("You now have full access to all Pro features!") },
confirmButton = {
TextButton(onClick = {
showSuccessAlert = false
onNavigateBack()
}) {
Text("Done")
}
}
)
}
}
}
@Composable
private fun FeatureRow(icon: ImageVector, text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SubscriptionProductsSection(
isProcessing: Boolean,
onProductSelected: (String) -> Unit,
onRetryLoad: () -> Unit
) {
// Static subscription options (pricing will be updated by platform billing)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Monthly Option
SubscriptionProductCard(
productId = "com.example.casera.pro.monthly",
name = "MyCrib Pro Monthly",
price = "$4.99/month",
description = "Billed monthly",
savingsBadge = null,
isSelected = false,
isProcessing = isProcessing,
onSelect = { onProductSelected("com.example.casera.pro.monthly") }
)
// Annual Option
SubscriptionProductCard(
productId = "com.example.casera.pro.annual",
name = "MyCrib Pro Annual",
price = "$39.99/year",
description = "Billed annually",
savingsBadge = "Save 33%",
isSelected = false,
isProcessing = isProcessing,
onSelect = { onProductSelected("com.example.casera.pro.annual") }
)
}
}
@Composable
private fun SubscriptionProductCard(
productId: String,
name: String,
price: String,
description: String,
savingsBadge: String?,
isSelected: Boolean,
isProcessing: Boolean,
onSelect: () -> Unit
) {
Card(
onClick = onSelect,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
),
border = if (isSelected)
androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
else
null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
savingsBadge?.let { badge ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.tertiaryContainer
) {
Text(
badge,
modifier = Modifier.padding(
horizontal = AppSpacing.sm,
vertical = 2.dp
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Bold
)
}
}
}
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Text(
price,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@@ -0,0 +1,155 @@
package com.example.casera.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
@Composable
fun UpgradePromptDialog(
triggerKey: String,
onDismiss: () -> Unit,
onUpgrade: () -> Unit
) {
val subscriptionCache = SubscriptionCache
val triggerData = subscriptionCache.upgradeTriggers.value[triggerKey]
var showFeatureComparison by remember { mutableStateOf(false) }
var isProcessing by remember { mutableStateOf(false) }
if (showFeatureComparison) {
FeatureComparisonDialog(
onDismiss = { showFeatureComparison = false },
onUpgrade = onUpgrade
)
} else {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Icon
Icon(
imageVector = Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.tertiary
)
// Title
Text(
triggerData?.title ?: "Upgrade to Pro",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Message
Text(
triggerData?.message ?: "Unlock unlimited access to all features",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(AppSpacing.sm))
// Pro Features Preview
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
FeatureRow(Icons.Default.Home, "Unlimited properties")
FeatureRow(Icons.Default.CheckCircle, "Unlimited tasks")
FeatureRow(Icons.Default.People, "Contractor management")
FeatureRow(Icons.Default.Description, "Document & warranty storage")
}
}
Spacer(Modifier.height(AppSpacing.sm))
// Upgrade Button
Button(
onClick = {
isProcessing = true
onUpgrade()
},
modifier = Modifier.fillMaxWidth(),
enabled = !isProcessing,
shape = MaterialTheme.shapes.medium
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
triggerData?.buttonText ?: "Upgrade to Pro",
fontWeight = FontWeight.Bold
)
}
}
// Compare Plans
TextButton(onClick = { showFeatureComparison = true }) {
Text("Compare Free vs Pro")
}
// Cancel
TextButton(onClick = onDismiss) {
Text("Maybe Later")
}
}
}
}
}
}
@Composable
private fun FeatureRow(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text,
style = MaterialTheme.typography.bodyMedium
)
}
}

Some files were not shown because too many files have changed in this diff Show More