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:
581
composeApp/src/commonMain/kotlin/com/example/casera/App.kt
Normal file
581
composeApp/src/commonMain/kotlin/com/example/casera/App.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.casera
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.casera
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
301
composeApp/src/commonMain/kotlin/com/example/casera/cache/DataCache.kt
vendored
Normal file
301
composeApp/src/commonMain/kotlin/com/example/casera/cache/DataCache.kt
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
200
composeApp/src/commonMain/kotlin/com/example/casera/cache/DataPrefetchManager.kt
vendored
Normal file
200
composeApp/src/commonMain/kotlin/com/example/casera/cache/DataPrefetchManager.kt
vendored
Normal 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!!
|
||||
}
|
||||
}
|
||||
}
|
||||
159
composeApp/src/commonMain/kotlin/com/example/casera/cache/README_CACHING.md
vendored
Normal file
159
composeApp/src/commonMain/kotlin/com/example/casera/cache/README_CACHING.md
vendored
Normal 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.
|
||||
37
composeApp/src/commonMain/kotlin/com/example/casera/cache/SubscriptionCache.kt
vendored
Normal file
37
composeApp/src/commonMain/kotlin/com/example/casera/cache/SubscriptionCache.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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()}")
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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]}"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user