Rebrand from Casera/MyCrib to honeyDue
Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
715
composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt
Normal file
715
composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt
Normal file
@@ -0,0 +1,715 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
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.tt.honeyDue.ui.screens.AddResidenceScreen
|
||||
import com.tt.honeyDue.ui.screens.EditResidenceScreen
|
||||
import com.tt.honeyDue.ui.screens.EditTaskScreen
|
||||
import com.tt.honeyDue.ui.screens.ForgotPasswordScreen
|
||||
import com.tt.honeyDue.ui.screens.HomeScreen
|
||||
import com.tt.honeyDue.ui.screens.LoginScreen
|
||||
import com.tt.honeyDue.ui.screens.RegisterScreen
|
||||
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
|
||||
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
|
||||
import com.tt.honeyDue.ui.screens.ResidencesScreen
|
||||
import com.tt.honeyDue.ui.screens.TasksScreen
|
||||
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
||||
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingScreen
|
||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||
import com.tt.honeyDue.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.tt.honeyDue.ui.screens.MainScreen
|
||||
import com.tt.honeyDue.ui.screens.ManageUsersScreen
|
||||
import com.tt.honeyDue.ui.screens.NotificationPreferencesScreen
|
||||
import com.tt.honeyDue.ui.screens.ProfileScreen
|
||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||
import com.tt.honeyDue.navigation.*
|
||||
import com.tt.honeyDue.repository.LookupsRepository
|
||||
import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.models.TaskCategory
|
||||
import com.tt.honeyDue.models.TaskDetail
|
||||
import com.tt.honeyDue.models.TaskFrequency
|
||||
import com.tt.honeyDue.models.TaskPriority
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.AuthApi
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.platform.ContractorImportHandler
|
||||
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
||||
import com.tt.honeyDue.platform.ResidenceImportHandler
|
||||
|
||||
import honeydue.composeapp.generated.resources.Res
|
||||
import honeydue.composeapp.generated.resources.compose_multiplatform
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App(
|
||||
deepLinkResetToken: String? = null,
|
||||
onClearDeepLinkToken: () -> Unit = {},
|
||||
navigateToTaskId: Int? = null,
|
||||
onClearNavigateToTask: () -> Unit = {},
|
||||
pendingContractorImportUri: Any? = null,
|
||||
onClearContractorImport: () -> Unit = {},
|
||||
pendingResidenceImportUri: Any? = null,
|
||||
onClearResidenceImport: () -> Unit = {}
|
||||
) {
|
||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||
var isVerified by remember { mutableStateOf(false) }
|
||||
var isCheckingAuth by remember { mutableStateOf(true) }
|
||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Handle navigation from notification tap
|
||||
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
|
||||
// We just need to ensure the user is on MainRoute when a task navigation is requested
|
||||
LaunchedEffect(navigateToTaskId) {
|
||||
if (navigateToTaskId != null && isLoggedIn && isVerified) {
|
||||
// Ensure we're on the main screen - MainScreen will handle navigating to the tasks tab
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo(MainRoute) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stored token and verification status on app start
|
||||
LaunchedEffect(Unit) {
|
||||
val hasToken = DataManager.authToken.value != null
|
||||
isLoggedIn = hasToken
|
||||
|
||||
if (hasToken) {
|
||||
// Fetch current user to check verification status
|
||||
when (val result = APILayer.getCurrentUser(forceRefresh = true)) {
|
||||
is ApiResult.Success -> {
|
||||
isVerified = result.data.verified
|
||||
APILayer.initializeLookups()
|
||||
}
|
||||
else -> {
|
||||
// If fetching user fails, clear DataManager and logout
|
||||
DataManager.clear()
|
||||
isLoggedIn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isCheckingAuth = false
|
||||
}
|
||||
|
||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||
|
||||
HoneyDueTheme(themeColors = currentTheme) {
|
||||
// Handle contractor file imports (Android-specific, no-op on other platforms)
|
||||
ContractorImportHandler(
|
||||
pendingContractorImportUri = pendingContractorImportUri,
|
||||
onClearContractorImport = onClearContractorImport
|
||||
)
|
||||
|
||||
// Handle residence file imports (Android-specific, no-op on other platforms)
|
||||
ResidenceImportHandler(
|
||||
pendingResidenceImportUri = pendingResidenceImportUri,
|
||||
onClearResidenceImport = onClearResidenceImport
|
||||
)
|
||||
|
||||
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@HoneyDueTheme
|
||||
}
|
||||
|
||||
val startDestination = when {
|
||||
deepLinkResetToken != null -> ForgotPasswordRoute
|
||||
!hasCompletedOnboarding -> OnboardingRoute
|
||||
!isLoggedIn -> LoginRoute
|
||||
!isVerified -> VerifyEmailRoute
|
||||
else -> MainRoute
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
composable<OnboardingRoute> {
|
||||
val onboardingViewModel: OnboardingViewModel = viewModel { OnboardingViewModel() }
|
||||
|
||||
OnboardingScreen(
|
||||
viewModel = onboardingViewModel,
|
||||
onComplete = {
|
||||
// Mark onboarding as complete
|
||||
DataManager.setHasCompletedOnboarding(true)
|
||||
hasCompletedOnboarding = true
|
||||
isLoggedIn = true
|
||||
isVerified = true
|
||||
// Note: Lookups are already initialized by APILayer during login/register
|
||||
|
||||
// Navigate to main screen
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo<OnboardingRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
onLoginSuccess = { verified ->
|
||||
// User logged in through onboarding login dialog
|
||||
DataManager.setHasCompletedOnboarding(true)
|
||||
hasCompletedOnboarding = true
|
||||
isLoggedIn = true
|
||||
isVerified = verified
|
||||
// Note: Lookups are already initialized by APILayer.login()
|
||||
|
||||
if (verified) {
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo<OnboardingRoute> { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
navController.navigate(VerifyEmailRoute) {
|
||||
popUpTo<OnboardingRoute> { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<LoginRoute> {
|
||||
LoginScreen(
|
||||
onLoginSuccess = { user ->
|
||||
isLoggedIn = true
|
||||
isVerified = user.verified
|
||||
// Note: Lookups are already initialized by APILayer.login()
|
||||
|
||||
// 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
|
||||
// Note: Lookups are already initialized by APILayer.register()
|
||||
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() }
|
||||
|
||||
// Set up auto-login callback
|
||||
LaunchedEffect(Unit) {
|
||||
passwordResetViewModel.onLoginSuccess = { verified ->
|
||||
isLoggedIn = true
|
||||
isVerified = verified
|
||||
onClearDeepLinkToken()
|
||||
// Navigate directly to main app or verification screen
|
||||
if (verified) {
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo<ForgotPasswordRoute> { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
navController.navigate(VerifyEmailRoute) {
|
||||
popUpTo<ForgotPasswordRoute> { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ResetPasswordScreen(
|
||||
onPasswordResetSuccess = {
|
||||
// Fallback: manual return to login (only shown if auto-login fails)
|
||||
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
|
||||
DataManager.clear()
|
||||
isLoggedIn = false
|
||||
isVerified = false
|
||||
navController.navigate(LoginRoute) {
|
||||
popUpTo<VerifyEmailRoute> { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<MainRoute> {
|
||||
MainScreen(
|
||||
onLogout = {
|
||||
// Clear token and lookups on logout
|
||||
DataManager.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"
|
||||
},
|
||||
navigateToTaskId = navigateToTaskId,
|
||||
onClearNavigateToTask = onClearNavigateToTask,
|
||||
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 ?: "",
|
||||
inProgress = task.inProgress,
|
||||
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
|
||||
DataManager.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
|
||||
DataManager.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 ?: "",
|
||||
inProgress = task.inProgress,
|
||||
dueDate = task.dueDate,
|
||||
estimatedCost = task.estimatedCost?.toString(),
|
||||
createdAt = task.createdAt,
|
||||
updatedAt = task.updatedAt
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToContractorDetail = { contractorId ->
|
||||
navController.navigate(ContractorDetailRoute(contractorId))
|
||||
},
|
||||
onNavigateToManageUsers = { residenceId, residenceName, isPrimaryOwner, ownerId ->
|
||||
navController.navigate(
|
||||
ManageUsersRoute(
|
||||
residenceId = residenceId,
|
||||
residenceName = residenceName,
|
||||
isPrimaryOwner = isPrimaryOwner,
|
||||
residenceOwnerId = ownerId
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<ManageUsersRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ManageUsersRoute>()
|
||||
ManageUsersScreen(
|
||||
residenceId = route.residenceId,
|
||||
residenceName = route.residenceName,
|
||||
isPrimaryOwner = route.isPrimaryOwner,
|
||||
residenceOwnerId = route.residenceOwnerId,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<UpgradeRoute> {
|
||||
PlatformUpgradeScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onSubscriptionChanged = {
|
||||
// Subscription state updated via DataManager
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
inProgress = route.inProgress,
|
||||
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
|
||||
DataManager.clear()
|
||||
isLoggedIn = false
|
||||
isVerified = false
|
||||
navController.navigate(LoginRoute) {
|
||||
popUpTo<ProfileRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToNotificationPreferences = {
|
||||
navController.navigate(NotificationPreferencesRoute)
|
||||
},
|
||||
onNavigateToUpgrade = {
|
||||
navController.navigate(UpgradeRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<NotificationPreferencesRoute> {
|
||||
NotificationPreferencesScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
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.tt.honeyDue
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//package com.honeydue.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.tt.honeyDue.ui.screens.*
|
||||
//import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||
//
|
||||
//class MainActivity : ComponentActivity() {
|
||||
// override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// super.onCreate(savedInstanceState)
|
||||
// setContent {
|
||||
// HoneyDueTheme {
|
||||
// HoneyDueApp()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.tt.honeyDue.analytics
|
||||
|
||||
/**
|
||||
* Common analytics interface for cross-platform event tracking.
|
||||
* Platform-specific implementations use PostHog SDKs.
|
||||
*/
|
||||
expect object PostHogAnalytics {
|
||||
fun initialize()
|
||||
fun identify(userId: String, properties: Map<String, Any>? = null)
|
||||
fun capture(event: String, properties: Map<String, Any>? = null)
|
||||
fun screen(screenName: String, properties: Map<String, Any>? = null)
|
||||
fun reset()
|
||||
fun flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics event names - use these constants for consistency across the app
|
||||
*/
|
||||
object AnalyticsEvents {
|
||||
// Authentication
|
||||
const val REGISTRATION_SCREEN_SHOWN = "registration_screen_shown"
|
||||
const val USER_REGISTERED = "user_registered"
|
||||
const val USER_SIGNED_IN = "user_signed_in"
|
||||
const val USER_SIGNED_IN_APPLE = "user_signed_in_apple"
|
||||
|
||||
// Residence
|
||||
const val RESIDENCE_SCREEN_SHOWN = "residence_screen_shown"
|
||||
const val NEW_RESIDENCE_SCREEN_SHOWN = "new_residence_screen_shown"
|
||||
const val RESIDENCE_CREATED = "residence_created"
|
||||
const val RESIDENCE_LIMIT_REACHED = "residence_limit_reached"
|
||||
|
||||
// Task
|
||||
const val TASK_SCREEN_SHOWN = "task_screen_shown"
|
||||
const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown"
|
||||
const val TASK_CREATED = "task_created"
|
||||
|
||||
// Contractor
|
||||
const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown"
|
||||
const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown"
|
||||
const val CONTRACTOR_CREATED = "contractor_created"
|
||||
const val CONTRACTOR_PAYWALL_SHOWN = "contractor_paywall_shown"
|
||||
|
||||
// Documents
|
||||
const val DOCUMENTS_SCREEN_SHOWN = "documents_screen_shown"
|
||||
const val NEW_DOCUMENT_SCREEN_SHOWN = "new_document_screen_shown"
|
||||
const val DOCUMENT_CREATED = "document_created"
|
||||
const val DOCUMENTS_PAYWALL_SHOWN = "documents_paywall_shown"
|
||||
|
||||
// Sharing
|
||||
const val SHARE_RESIDENCE_SCREEN_SHOWN = "share_residence_screen_shown"
|
||||
const val RESIDENCE_SHARED = "residence_shared"
|
||||
const val SHARE_RESIDENCE_PAYWALL_SHOWN = "share_residence_paywall_shown"
|
||||
const val SHARE_CONTRACTOR_SCREEN_SHOWN = "share_contractor_screen_shown"
|
||||
const val CONTRACTOR_SHARED = "contractor_shared"
|
||||
const val SHARE_CONTRACTOR_PAYWALL_SHOWN = "share_contractor_paywall_shown"
|
||||
|
||||
// Settings
|
||||
const val NOTIFICATION_SETTINGS_SCREEN_SHOWN = "notification_settings_screen_shown"
|
||||
const val SETTINGS_SCREEN_SHOWN = "settings_screen_shown"
|
||||
const val THEME_CHANGED = "theme_changed"
|
||||
}
|
||||
66
composeApp/src/commonMain/kotlin/com/tt/honeyDue/cache/SubscriptionCache.kt
vendored
Normal file
66
composeApp/src/commonMain/kotlin/com/tt/honeyDue/cache/SubscriptionCache.kt
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.tt.honeyDue.cache
|
||||
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.FeatureBenefit
|
||||
import com.tt.honeyDue.models.Promotion
|
||||
import com.tt.honeyDue.models.SubscriptionStatus
|
||||
import com.tt.honeyDue.models.UpgradeTriggerData
|
||||
|
||||
/**
|
||||
* Thin facade over DataManager for subscription data.
|
||||
*
|
||||
* All state is delegated to DataManager (single source of truth).
|
||||
* This object exists for backwards compatibility with callers that
|
||||
* read subscription state (e.g. iOS SubscriptionCacheWrapper polling via Kotlin interop).
|
||||
*
|
||||
* For Compose UI code, prefer using DataManager StateFlows directly with collectAsState().
|
||||
*/
|
||||
object SubscriptionCache {
|
||||
/**
|
||||
* Current subscription status, delegated to DataManager.
|
||||
* For Compose callers, prefer: `val subscription by DataManager.subscription.collectAsState()`
|
||||
*/
|
||||
val currentSubscription: SubscriptionCacheAccessor<SubscriptionStatus?>
|
||||
get() = SubscriptionCacheAccessor { DataManager.subscription.value }
|
||||
|
||||
val upgradeTriggers: SubscriptionCacheAccessor<Map<String, UpgradeTriggerData>>
|
||||
get() = SubscriptionCacheAccessor { DataManager.upgradeTriggers.value }
|
||||
|
||||
val featureBenefits: SubscriptionCacheAccessor<List<FeatureBenefit>>
|
||||
get() = SubscriptionCacheAccessor { DataManager.featureBenefits.value }
|
||||
|
||||
val promotions: SubscriptionCacheAccessor<List<Promotion>>
|
||||
get() = SubscriptionCacheAccessor { DataManager.promotions.value }
|
||||
|
||||
fun updateSubscriptionStatus(subscription: SubscriptionStatus) {
|
||||
DataManager.setSubscription(subscription)
|
||||
}
|
||||
|
||||
fun updateUpgradeTriggers(triggers: Map<String, UpgradeTriggerData>) {
|
||||
DataManager.setUpgradeTriggers(triggers)
|
||||
}
|
||||
|
||||
fun updateFeatureBenefits(benefits: List<FeatureBenefit>) {
|
||||
DataManager.setFeatureBenefits(benefits)
|
||||
}
|
||||
|
||||
fun updatePromotions(promos: List<Promotion>) {
|
||||
DataManager.setPromotions(promos)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
DataManager.setSubscription(null)
|
||||
DataManager.setUpgradeTriggers(emptyMap())
|
||||
DataManager.setFeatureBenefits(emptyList())
|
||||
DataManager.setPromotions(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple accessor that provides .value to read from DataManager.
|
||||
* This preserves the `SubscriptionCache.currentSubscription.value` call pattern
|
||||
* used by existing callers (Kotlin code and iOS interop polling).
|
||||
*/
|
||||
class SubscriptionCacheAccessor<T>(private val getter: () -> T) {
|
||||
val value: T get() = getter()
|
||||
}
|
||||
1001
composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt
Normal file
1001
composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
package com.tt.honeyDue.data
|
||||
|
||||
/**
|
||||
* Platform-specific persistence manager for storing app data to disk.
|
||||
* Each platform implements this using their native storage mechanisms.
|
||||
*
|
||||
* Android: SharedPreferences
|
||||
* iOS: UserDefaults
|
||||
* JVM: Properties file
|
||||
* Wasm: LocalStorage
|
||||
*/
|
||||
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||
expect class PersistenceManager {
|
||||
/**
|
||||
* Save a string value to persistent storage.
|
||||
*/
|
||||
fun save(key: String, value: String)
|
||||
|
||||
/**
|
||||
* Load a string value from persistent storage.
|
||||
* Returns null if the key doesn't exist.
|
||||
*/
|
||||
fun load(key: String): String?
|
||||
|
||||
/**
|
||||
* Remove a specific key from storage.
|
||||
*/
|
||||
fun remove(key: String)
|
||||
|
||||
/**
|
||||
* Clear all stored data.
|
||||
*/
|
||||
fun clear()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Shared encoder/decoder for `.honeydue` payloads across Android and iOS.
|
||||
*
|
||||
* This keeps package JSON shape in one place while each platform owns
|
||||
* native share-sheet presentation details.
|
||||
*/
|
||||
object honeyDueShareCodec {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encodeContractorPackage(contractor: Contractor, exportedBy: String? = null): String {
|
||||
return encodeSharedContractor(contractor.toSharedContractor(exportedBy))
|
||||
}
|
||||
|
||||
fun encodeSharedContractor(sharedContractor: SharedContractor): String {
|
||||
return json.encodeToString(SharedContractor.serializer(), sharedContractor)
|
||||
}
|
||||
|
||||
fun encodeSharedResidence(sharedResidence: SharedResidence): String {
|
||||
return json.encodeToString(SharedResidence.serializer(), sharedResidence)
|
||||
}
|
||||
|
||||
fun decodeSharedContractorOrNull(jsonContent: String): SharedContractor? {
|
||||
return try {
|
||||
json.decodeFromString(SharedContractor.serializer(), jsonContent)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeSharedResidenceOrNull(jsonContent: String): SharedResidence? {
|
||||
return try {
|
||||
json.decodeFromString(SharedResidence.serializer(), jsonContent)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun createContractorImportRequestOrNull(
|
||||
jsonContent: String,
|
||||
availableSpecialties: List<ContractorSpecialty>
|
||||
): ContractorCreateRequest? {
|
||||
val shared = decodeSharedContractorOrNull(jsonContent) ?: return null
|
||||
val specialtyIds = shared.resolveSpecialtyIds(availableSpecialties)
|
||||
return shared.toCreateRequest(specialtyIds)
|
||||
}
|
||||
|
||||
fun extractResidenceShareCodeOrNull(jsonContent: String): String? {
|
||||
return decodeSharedResidenceOrNull(jsonContent)?.shareCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a filesystem-safe package filename with `.honeydue` extension.
|
||||
*/
|
||||
fun safeShareFileName(displayName: String): String {
|
||||
val safeName = displayName
|
||||
.replace(" ", "_")
|
||||
.replace("/", "-")
|
||||
.take(50)
|
||||
return "$safeName.honeydue"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ContractorUser(
|
||||
val id: Int,
|
||||
val username: String,
|
||||
@SerialName("first_name") val firstName: String? = null,
|
||||
@SerialName("last_name") val lastName: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Contractor(
|
||||
val id: Int,
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
@SerialName("created_by_id") val createdById: Int,
|
||||
@SerialName("added_by") val addedBy: Int,
|
||||
@SerialName("created_by") val createdBy: ContractorUser? = null,
|
||||
val name: String,
|
||||
val company: String? = null,
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val website: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("street_address") val streetAddress: String? = null,
|
||||
val city: String? = null,
|
||||
@SerialName("state_province") val stateProvince: String? = null,
|
||||
@SerialName("postal_code") val postalCode: String? = null,
|
||||
val specialties: List<ContractorSpecialty> = emptyList(),
|
||||
val rating: Double? = null,
|
||||
@SerialName("is_favorite") val isFavorite: Boolean = false,
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("task_count") val taskCount: Int = 0,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContractorCreateRequest(
|
||||
val name: String,
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
val company: String? = null,
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val website: String? = null,
|
||||
@SerialName("street_address") val streetAddress: String? = null,
|
||||
val city: String? = null,
|
||||
@SerialName("state_province") val stateProvince: String? = null,
|
||||
@SerialName("postal_code") val postalCode: String? = null,
|
||||
val rating: Double? = null,
|
||||
@SerialName("is_favorite") val isFavorite: Boolean = false,
|
||||
val notes: String? = null,
|
||||
@SerialName("specialty_ids") val specialtyIds: List<Int>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContractorUpdateRequest(
|
||||
val name: String? = null,
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
val company: String? = null,
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val website: String? = null,
|
||||
@SerialName("street_address") val streetAddress: String? = null,
|
||||
val city: String? = null,
|
||||
@SerialName("state_province") val stateProvince: String? = null,
|
||||
@SerialName("postal_code") val postalCode: String? = null,
|
||||
val rating: Double? = null,
|
||||
@SerialName("is_favorite") val isFavorite: Boolean? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("specialty_ids") val specialtyIds: List<Int>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContractorSummary(
|
||||
val id: Int,
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
val name: String,
|
||||
val company: String? = null,
|
||||
val phone: String? = null,
|
||||
val specialties: List<ContractorSpecialty> = emptyList(),
|
||||
val rating: Double? = null,
|
||||
@SerialName("is_favorite") val isFavorite: Boolean = false,
|
||||
@SerialName("task_count") val taskCount: Int = 0
|
||||
)
|
||||
|
||||
// Extension to convert full Contractor to ContractorSummary
|
||||
fun Contractor.toSummary() = ContractorSummary(
|
||||
id = id,
|
||||
residenceId = residenceId,
|
||||
name = name,
|
||||
company = company,
|
||||
phone = phone,
|
||||
specialties = specialties,
|
||||
rating = rating,
|
||||
isFavorite = isFavorite,
|
||||
taskCount = taskCount
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
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("in_progress") val inProgress: Boolean = false,
|
||||
@SerialName("frequency_id") val frequencyId: Int? = null,
|
||||
val frequency: TaskFrequency? = null,
|
||||
@SerialName("custom_interval_days") val customIntervalDays: Int? = null, // For "Custom" frequency, user-specified days
|
||||
@SerialName("due_date") val dueDate: String? = null,
|
||||
@SerialName("next_due_date") val nextDueDate: String? = null, // For recurring tasks, updated after each completion
|
||||
@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,
|
||||
@SerialName("kanban_column") val kanbanColumn: String? = null, // Which kanban column this task belongs to
|
||||
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 - resolves from cache if not embedded in response
|
||||
// This allows the API to skip Preloading these lookups for performance
|
||||
val categoryName: String? get() = category?.name ?: DataManager.getTaskCategory(categoryId)?.name
|
||||
val categoryDescription: String? get() = category?.description ?: DataManager.getTaskCategory(categoryId)?.description
|
||||
val frequencyName: String? get() = frequency?.name ?: DataManager.getTaskFrequency(frequencyId)?.name
|
||||
val frequencyDisplayName: String? get() = frequency?.displayName ?: DataManager.getTaskFrequency(frequencyId)?.displayName
|
||||
val frequencyDaySpan: Int? get() = frequency?.days ?: DataManager.getTaskFrequency(frequencyId)?.days
|
||||
val priorityName: String? get() = priority?.name ?: DataManager.getTaskPriority(priorityId)?.name
|
||||
val priorityDisplayName: String? get() = priority?.displayName ?: DataManager.getTaskPriority(priorityId)?.displayName
|
||||
|
||||
// Effective due date: use nextDueDate if set (for recurring tasks), otherwise use dueDate
|
||||
val effectiveDueDate: String? get() = nextDueDate ?: dueDate
|
||||
|
||||
// Fields that don't exist in Go API - return null/default
|
||||
val nextScheduledDate: String? get() = nextDueDate // Now we have this from the API
|
||||
val showCompletedButton: Boolean get() = true // Always show complete button since status is now just in_progress boolean
|
||||
val daySpan: Int? get() = frequency?.days ?: DataManager.getTaskFrequency(frequencyId)?.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,
|
||||
@SerialName("task") val updatedTask: TaskResponse? = null // Updated task after completion (for UI kanban update)
|
||||
) {
|
||||
// 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("in_progress") val inProgress: Boolean = false,
|
||||
@SerialName("frequency_id") val frequencyId: Int? = null,
|
||||
@SerialName("custom_interval_days") val customIntervalDays: Int? = null, // For "Custom" frequency
|
||||
@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("in_progress") val inProgress: Boolean? = null,
|
||||
@SerialName("frequency_id") val frequencyId: Int? = null,
|
||||
@SerialName("custom_interval_days") val customIntervalDays: Int? = null, // For "Custom" frequency
|
||||
@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
|
||||
* NOTE: Summary statistics are calculated client-side from kanban data
|
||||
*/
|
||||
@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("in_progress") val inProgress: Boolean? = 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,
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/completion-image/{id}
|
||||
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.tt.honeyDue.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 DocumentUser(
|
||||
val id: Int,
|
||||
val username: String = "",
|
||||
@SerialName("first_name") val firstName: String = "",
|
||||
@SerialName("last_name") val lastName: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentImage(
|
||||
val id: Int? = null,
|
||||
@SerialName("image_url") val imageUrl: String,
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document-image/{id}
|
||||
val caption: String? = null,
|
||||
@SerialName("uploaded_at") val uploadedAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentActionResponse(
|
||||
val message: String,
|
||||
val document: Document
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Document(
|
||||
val id: Int? = null,
|
||||
val title: String,
|
||||
@SerialName("document_type") val documentType: String,
|
||||
val description: String? = null,
|
||||
@SerialName("file_url") val fileUrl: String? = null, // URL to the file
|
||||
@SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document/{id}
|
||||
@SerialName("file_name") val fileName: String? = null,
|
||||
@SerialName("file_size") val fileSize: Int? = null,
|
||||
@SerialName("mime_type") val mimeType: String? = null,
|
||||
// Warranty-specific fields
|
||||
@SerialName("model_number") val modelNumber: String? = null,
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("residence_id") val residenceId: Int? = null,
|
||||
val residence: Int,
|
||||
@SerialName("created_by_id") val createdById: Int? = null,
|
||||
@SerialName("created_by") val createdBy: DocumentUser? = null,
|
||||
@SerialName("task_id") val taskId: Int? = null,
|
||||
// Images
|
||||
val images: List<DocumentImage> = emptyList(),
|
||||
// Status
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
// Client-side convenience fields (not from backend, kept for UI compatibility)
|
||||
// These fields are populated client-side or kept optional so deserialization doesn't fail
|
||||
val category: String? = null,
|
||||
val tags: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("item_name") val itemName: 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("start_date") val startDate: String? = null,
|
||||
@SerialName("days_until_expiration") val daysUntilExpiration: Int? = null,
|
||||
@SerialName("warranty_status") val warrantyStatus: WarrantyStatus? = null
|
||||
) {
|
||||
// Backward-compatible alias: endDate maps to expiryDate
|
||||
val endDate: String? get() = expiryDate
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DocumentCreateRequest(
|
||||
val title: String,
|
||||
@SerialName("document_type") val documentType: String,
|
||||
val description: String? = null,
|
||||
// Note: file will be handled separately as multipart/form-data
|
||||
// Warranty-specific fields
|
||||
@SerialName("model_number") val modelNumber: String? = null,
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("residence_id") val residenceId: Int,
|
||||
@SerialName("task_id") val taskId: Int? = null,
|
||||
// Images
|
||||
@SerialName("image_urls") val imageUrls: List<String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentUpdateRequest(
|
||||
val title: String? = null,
|
||||
@SerialName("document_type") val documentType: String? = null,
|
||||
val description: String? = null,
|
||||
// Warranty-specific fields
|
||||
@SerialName("model_number") val modelNumber: String? = null,
|
||||
@SerialName("serial_number") val serialNumber: String? = null,
|
||||
val vendor: String? = null,
|
||||
@SerialName("purchase_date") val purchaseDate: String? = null,
|
||||
@SerialName("purchase_price") val purchasePrice: String? = null,
|
||||
@SerialName("expiry_date") val expiryDate: String? = null,
|
||||
// Relationships
|
||||
@SerialName("task_id") val taskId: Int? = 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,17 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Error response model that handles the backend's error format.
|
||||
* The backend returns: {"error": "message"}
|
||||
* All fields except 'error' are optional for backwards compatibility.
|
||||
*/
|
||||
@Serializable
|
||||
data class ErrorResponse(
|
||||
val error: String,
|
||||
val detail: String? = null,
|
||||
@SerialName("status_code") val statusCode: Int? = null,
|
||||
val errors: Map<String, List<String>>? = null
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.tt.honeyDue.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 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,
|
||||
val description: String? = null,
|
||||
val icon: String? = null,
|
||||
@SerialName("display_order") val displayOrder: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* Minimal contractor info for task references
|
||||
*/
|
||||
@Serializable
|
||||
data class ContractorMinimal(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val company: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 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_categories") val taskCategories: List<TaskCategory>,
|
||||
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified seeded data response - all lookups + task templates in one call
|
||||
* Supports ETag-based conditional fetching for efficient caching
|
||||
*/
|
||||
@Serializable
|
||||
data class SeededDataResponse(
|
||||
@SerialName("residence_types") val residenceTypes: List<ResidenceType>,
|
||||
@SerialName("task_categories") val taskCategories: List<TaskCategory>,
|
||||
@SerialName("task_priorities") val taskPriorities: List<TaskPriority>,
|
||||
@SerialName("task_frequencies") val taskFrequencies: List<TaskFrequency>,
|
||||
@SerialName("contractor_specialties") val contractorSpecialties: List<ContractorSpecialty>,
|
||||
@SerialName("task_templates") val taskTemplates: TaskTemplatesGroupedResponse
|
||||
)
|
||||
|
||||
// Legacy wrapper responses for backward compatibility
|
||||
// These can be removed once all code is migrated to use arrays directly
|
||||
|
||||
@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 TaskCategoryResponse(
|
||||
val count: Int,
|
||||
val results: List<TaskCategory>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContractorSpecialtyResponse(
|
||||
val count: Int,
|
||||
val results: List<ContractorSpecialty>
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DeviceRegistrationRequest(
|
||||
@SerialName("device_id")
|
||||
val deviceId: String,
|
||||
@SerialName("registration_id")
|
||||
val registrationId: String,
|
||||
val platform: String, // "android" or "ios"
|
||||
val name: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceRegistrationResponse(
|
||||
val id: Int,
|
||||
val name: String? = null,
|
||||
@SerialName("device_id")
|
||||
val deviceId: String,
|
||||
@SerialName("registration_id")
|
||||
val registrationId: String,
|
||||
val platform: String,
|
||||
val active: Boolean,
|
||||
@SerialName("date_created")
|
||||
val dateCreated: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationPreference(
|
||||
@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("daily_digest")
|
||||
val dailyDigest: Boolean = true,
|
||||
// Email preferences
|
||||
@SerialName("email_task_completed")
|
||||
val emailTaskCompleted: Boolean = true,
|
||||
// Custom notification times (UTC hour 0-23, null means use system default)
|
||||
@SerialName("task_due_soon_hour")
|
||||
val taskDueSoonHour: Int? = null,
|
||||
@SerialName("task_overdue_hour")
|
||||
val taskOverdueHour: Int? = null,
|
||||
@SerialName("warranty_expiring_hour")
|
||||
val warrantyExpiringHour: Int? = null,
|
||||
@SerialName("daily_digest_hour")
|
||||
val dailyDigestHour: Int? = null
|
||||
)
|
||||
|
||||
@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,
|
||||
@SerialName("daily_digest")
|
||||
val dailyDigest: Boolean? = null,
|
||||
// Email preferences
|
||||
@SerialName("email_task_completed")
|
||||
val emailTaskCompleted: Boolean? = null,
|
||||
// Custom notification times (UTC hour 0-23)
|
||||
@SerialName("task_due_soon_hour")
|
||||
val taskDueSoonHour: Int? = null,
|
||||
@SerialName("task_overdue_hour")
|
||||
val taskOverdueHour: Int? = null,
|
||||
@SerialName("warranty_expiring_hour")
|
||||
val warrantyExpiringHour: Int? = null,
|
||||
@SerialName("daily_digest_hour")
|
||||
val dailyDigestHour: Int? = 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 NotificationListResponse(
|
||||
val count: Int,
|
||||
val results: List<Notification>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnreadCountResponse(
|
||||
@SerialName("unread_count")
|
||||
val unreadCount: Int
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.tt.honeyDue.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("overdue_count") val overdueCount: Int = 0,
|
||||
@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,
|
||||
val summary: TotalSummary
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
/**
|
||||
* Generic wrapper for CRUD responses that include TotalSummary.
|
||||
* Used for Task and TaskCompletion operations to eliminate extra API calls
|
||||
* for updating dashboard stats.
|
||||
*
|
||||
* Usage examples:
|
||||
* - WithSummaryResponse<TaskResponse> for task CRUD
|
||||
* - WithSummaryResponse<TaskCompletionResponse> for completion CRUD
|
||||
* - WithSummaryResponse<String> for delete operations (data = "task deleted")
|
||||
*/
|
||||
@Serializable
|
||||
data class WithSummaryResponse<T>(
|
||||
val data: T,
|
||||
val summary: TotalSummary
|
||||
)
|
||||
|
||||
/**
|
||||
* My residences response - list of user's residences
|
||||
* NOTE: Summary statistics are calculated client-side from kanban data
|
||||
*/
|
||||
@Serializable
|
||||
data class MyResidencesResponse(
|
||||
val residences: List<ResidenceResponse>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 - API returns a flat list of all users with access
|
||||
*/
|
||||
typealias ResidenceUsersResponse = 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,173 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Package type identifiers for .honeydue files
|
||||
*/
|
||||
object honeyDuePackageType {
|
||||
const val CONTRACTOR = "contractor"
|
||||
const val RESIDENCE = "residence"
|
||||
}
|
||||
|
||||
/**
|
||||
* Data model for .honeydue file format used to share contractors between users.
|
||||
* Contains only the data needed to recreate a contractor, without server-specific IDs.
|
||||
*/
|
||||
@Serializable
|
||||
data class SharedContractor(
|
||||
/** File format version for future compatibility */
|
||||
val version: Int = 1,
|
||||
|
||||
/** Package type discriminator */
|
||||
val type: String = honeyDuePackageType.CONTRACTOR,
|
||||
|
||||
val name: String,
|
||||
val company: String? = null,
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val website: String? = null,
|
||||
val notes: String? = null,
|
||||
|
||||
@SerialName("street_address")
|
||||
val streetAddress: String? = null,
|
||||
val city: String? = null,
|
||||
@SerialName("state_province")
|
||||
val stateProvince: String? = null,
|
||||
@SerialName("postal_code")
|
||||
val postalCode: String? = null,
|
||||
|
||||
/** Specialty names (not IDs) for cross-account compatibility */
|
||||
@SerialName("specialty_names")
|
||||
val specialtyNames: List<String> = emptyList(),
|
||||
|
||||
val rating: Double? = null,
|
||||
@SerialName("is_favorite")
|
||||
val isFavorite: Boolean = false,
|
||||
|
||||
/** ISO8601 timestamp when the contractor was exported */
|
||||
@SerialName("exported_at")
|
||||
val exportedAt: String? = null,
|
||||
|
||||
/** Username of the person who exported the contractor */
|
||||
@SerialName("exported_by")
|
||||
val exportedBy: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Data model for .honeydue file format used to share residences between users.
|
||||
* Contains the share code needed to join the residence.
|
||||
*/
|
||||
@Serializable
|
||||
data class SharedResidence(
|
||||
/** File format version for future compatibility */
|
||||
val version: Int = 1,
|
||||
|
||||
/** Package type discriminator */
|
||||
val type: String = honeyDuePackageType.RESIDENCE,
|
||||
|
||||
/** The share code for joining the residence */
|
||||
@SerialName("share_code")
|
||||
val shareCode: String,
|
||||
|
||||
/** Name of the residence being shared */
|
||||
@SerialName("residence_name")
|
||||
val residenceName: String,
|
||||
|
||||
/** Email of the person sharing the residence */
|
||||
@SerialName("shared_by")
|
||||
val sharedBy: String? = null,
|
||||
|
||||
/** ISO8601 timestamp when the code expires */
|
||||
@SerialName("expires_at")
|
||||
val expiresAt: String? = null,
|
||||
|
||||
/** ISO8601 timestamp when the package was created */
|
||||
@SerialName("exported_at")
|
||||
val exportedAt: String? = null,
|
||||
|
||||
/** Username of the person who created the package */
|
||||
@SerialName("exported_by")
|
||||
val exportedBy: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Detect the type of a .honeydue package from its JSON content.
|
||||
* Returns null if the type cannot be determined.
|
||||
*/
|
||||
fun detecthoneyDuePackageType(jsonContent: String): String? {
|
||||
return try {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val jsonObject = json.decodeFromString<JsonObject>(jsonContent)
|
||||
jsonObject["type"]?.jsonPrimitive?.content ?: honeyDuePackageType.CONTRACTOR // Default for backward compatibility
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full Contractor to SharedContractor for export.
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun Contractor.toSharedContractor(exportedBy: String? = null): SharedContractor {
|
||||
return SharedContractor(
|
||||
version = 1,
|
||||
type = honeyDuePackageType.CONTRACTOR,
|
||||
name = name,
|
||||
company = company,
|
||||
phone = phone,
|
||||
email = email,
|
||||
website = website,
|
||||
notes = notes,
|
||||
streetAddress = streetAddress,
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
specialtyNames = specialties.map { it.name },
|
||||
rating = rating,
|
||||
isFavorite = isFavorite,
|
||||
exportedAt = Clock.System.now().toString(),
|
||||
exportedBy = exportedBy
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SharedContractor to ContractorCreateRequest for import.
|
||||
* @param specialtyIds The resolved specialty IDs from the importing account's lookup data
|
||||
*/
|
||||
fun SharedContractor.toCreateRequest(specialtyIds: List<Int>): ContractorCreateRequest {
|
||||
return ContractorCreateRequest(
|
||||
name = name,
|
||||
residenceId = null, // Imported contractors have no residence association
|
||||
company = company,
|
||||
phone = phone,
|
||||
email = email,
|
||||
website = website,
|
||||
streetAddress = streetAddress,
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
rating = rating,
|
||||
isFavorite = isFavorite,
|
||||
notes = notes,
|
||||
specialtyIds = specialtyIds.ifEmpty { null }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve specialty names to IDs using the available specialties in the importing account.
|
||||
* Case-insensitive matching.
|
||||
*/
|
||||
fun SharedContractor.resolveSpecialtyIds(availableSpecialties: List<ContractorSpecialty>): List<Int> {
|
||||
return specialtyNames.mapNotNull { name ->
|
||||
availableSpecialties.find { specialty ->
|
||||
specialty.name.equals(name, ignoreCase = true)
|
||||
}?.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionStatus(
|
||||
val tier: String = "free",
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@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
|
||||
@SerialName("trial_start") val trialStart: String? = null,
|
||||
@SerialName("trial_end") val trialEnd: String? = null,
|
||||
@SerialName("trial_active") val trialActive: Boolean = false,
|
||||
@SerialName("subscription_source") val subscriptionSource: String? = null,
|
||||
)
|
||||
|
||||
@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_text") val freeTierText: String,
|
||||
@SerialName("pro_tier_text") val proTierText: 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
|
||||
)
|
||||
|
||||
/**
|
||||
* Nested subscription info returned by backend purchase/restore endpoints.
|
||||
*/
|
||||
@Serializable
|
||||
data class VerificationSubscriptionInfo(
|
||||
val tier: String = "",
|
||||
@SerialName("subscribed_at") val subscribedAt: String? = null,
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
@SerialName("auto_renew") val autoRenew: Boolean = true,
|
||||
@SerialName("cancelled_at") val cancelledAt: String? = null,
|
||||
val platform: String = "",
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@SerialName("is_pro") val isPro: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Response from backend purchase/restore endpoints.
|
||||
* Backend returns: { "message": "...", "subscription": { "tier": "pro", ... } }
|
||||
*/
|
||||
@Serializable
|
||||
data class VerificationResponse(
|
||||
val message: String = "",
|
||||
val subscription: VerificationSubscriptionInfo? = null
|
||||
) {
|
||||
/** Backward-compatible: success when subscription is present */
|
||||
val success: Boolean get() = subscription != null
|
||||
|
||||
/** Backward-compatible: tier extracted from nested subscription */
|
||||
val tier: String? get() = subscription?.tier
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.tt.honeyDue.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,56 @@
|
||||
package com.tt.honeyDue.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a task template fetched from the backend API.
|
||||
* Users can select these when adding a new task to auto-fill form fields.
|
||||
*/
|
||||
@Serializable
|
||||
data class TaskTemplate(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
@SerialName("category_id") val categoryId: Int? = null,
|
||||
val category: TaskCategory? = null,
|
||||
@SerialName("frequency_id") val frequencyId: Int? = null,
|
||||
val frequency: TaskFrequency? = null,
|
||||
@SerialName("icon_ios") val iconIos: String = "",
|
||||
@SerialName("icon_android") val iconAndroid: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
@SerialName("display_order") val displayOrder: Int = 0,
|
||||
@SerialName("is_active") val isActive: Boolean = true
|
||||
) {
|
||||
/**
|
||||
* Human-readable frequency display
|
||||
*/
|
||||
val frequencyDisplay: String
|
||||
get() = frequency?.displayName ?: "One time"
|
||||
|
||||
/**
|
||||
* Category name for display
|
||||
*/
|
||||
val categoryName: String
|
||||
get() = category?.name ?: "Uncategorized"
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for grouped templates by category
|
||||
*/
|
||||
@Serializable
|
||||
data class TaskTemplateCategoryGroup(
|
||||
@SerialName("category_name") val categoryName: String,
|
||||
@SerialName("category_id") val categoryId: Int? = null,
|
||||
val templates: List<TaskTemplate>,
|
||||
val count: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for all templates grouped by category
|
||||
*/
|
||||
@Serializable
|
||||
data class TaskTemplatesGroupedResponse(
|
||||
val categories: List<TaskTemplateCategoryGroup>,
|
||||
@SerialName("total_count") val totalCount: Int
|
||||
)
|
||||
208
composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
Normal file
208
composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
package com.tt.honeyDue.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
|
||||
)
|
||||
|
||||
// Apple Sign In Models
|
||||
|
||||
/**
|
||||
* Apple Sign In request matching Go API
|
||||
*/
|
||||
@Serializable
|
||||
data class AppleSignInRequest(
|
||||
@SerialName("id_token") val idToken: String,
|
||||
@SerialName("user_id") val userId: String,
|
||||
val email: String? = null,
|
||||
@SerialName("first_name") val firstName: String? = null,
|
||||
@SerialName("last_name") val lastName: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Apple Sign In response matching Go API
|
||||
*/
|
||||
@Serializable
|
||||
data class AppleSignInResponse(
|
||||
val token: String,
|
||||
val user: User,
|
||||
@SerialName("is_new_user") val isNewUser: Boolean
|
||||
)
|
||||
|
||||
// Google Sign In Models
|
||||
|
||||
/**
|
||||
* Google Sign In request matching Go API
|
||||
*/
|
||||
@Serializable
|
||||
data class GoogleSignInRequest(
|
||||
@SerialName("id_token") val idToken: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Google Sign In response matching Go API
|
||||
*/
|
||||
@Serializable
|
||||
data class GoogleSignInResponse(
|
||||
val token: String,
|
||||
val user: User,
|
||||
@SerialName("is_new_user") val isNewUser: Boolean
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.tt.honeyDue.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 inProgress: Boolean,
|
||||
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
|
||||
|
||||
@Serializable
|
||||
object NotificationPreferencesRoute
|
||||
|
||||
// Onboarding Routes
|
||||
@Serializable
|
||||
object OnboardingRoute
|
||||
|
||||
// Task Completion Route
|
||||
@Serializable
|
||||
data class CompleteTaskRoute(
|
||||
val taskId: Int,
|
||||
val taskTitle: String,
|
||||
val residenceName: String
|
||||
)
|
||||
|
||||
// Manage Users Route
|
||||
@Serializable
|
||||
data class ManageUsersRoute(
|
||||
val residenceId: Int,
|
||||
val residenceName: String,
|
||||
val isPrimaryOwner: Boolean,
|
||||
val residenceOwnerId: Int
|
||||
)
|
||||
|
||||
// Photo Viewer Route
|
||||
@Serializable
|
||||
data class PhotoViewerRoute(
|
||||
val imageUrls: String, // JSON encoded list
|
||||
val initialIndex: Int = 0
|
||||
)
|
||||
|
||||
// Upgrade/Subscription Route
|
||||
@Serializable
|
||||
object UpgradeRoute
|
||||
1463
composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
Normal file
1463
composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
package com.tt.honeyDue.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
|
||||
|
||||
/**
|
||||
* Get the device's preferred language code (e.g., "en", "es", "fr").
|
||||
* This is used to set the Accept-Language header for API requests
|
||||
* so the server can return localized error messages and content.
|
||||
*/
|
||||
expect fun getDeviceLanguage(): String
|
||||
|
||||
/**
|
||||
* Get the device's timezone identifier (e.g., "America/Los_Angeles", "Europe/London").
|
||||
* This is used to set the X-Timezone header for API requests
|
||||
* so the server can calculate overdue tasks correctly based on the user's local time.
|
||||
*/
|
||||
expect fun getDeviceTimezone(): String
|
||||
|
||||
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,73 @@
|
||||
package com.tt.honeyDue.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://honeyDue.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://honeyDue.treytartt.com"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment name for logging
|
||||
*/
|
||||
fun getEnvironmentName(): String {
|
||||
return when (CURRENT_ENV) {
|
||||
Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)"
|
||||
Environment.DEV -> "Dev Server (honeyDue.treytartt.com)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth Web Client ID
|
||||
* This is the Web application client ID from Google Cloud Console.
|
||||
* It should match the GOOGLE_CLIENT_ID configured in the backend.
|
||||
*
|
||||
* Set via environment: actual client ID must be configured per environment.
|
||||
* To get this value:
|
||||
* 1. Go to Google Cloud Console -> APIs & Services -> Credentials
|
||||
* 2. Create or use an existing OAuth 2.0 Client ID of type "Web application"
|
||||
* 3. Copy the Client ID (format: xxx.apps.googleusercontent.com)
|
||||
* 4. Replace the empty string below with your client ID
|
||||
*
|
||||
* WARNING: An empty string means Google Sign-In is not configured.
|
||||
* The app should check [isGoogleSignInConfigured] before offering Google Sign-In.
|
||||
*/
|
||||
const val GOOGLE_WEB_CLIENT_ID = ""
|
||||
|
||||
/**
|
||||
* Whether Google Sign-In has been configured with a real client ID.
|
||||
* UI should check this before showing Google Sign-In buttons.
|
||||
*/
|
||||
val isGoogleSignInConfigured: Boolean
|
||||
get() = GOOGLE_WEB_CLIENT_ID.isNotEmpty()
|
||||
&& !GOOGLE_WEB_CLIENT_ID.contains("YOUR_")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.tt.honeyDue.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,253 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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 {
|
||||
// Parse actual error message from backend
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, 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 {
|
||||
// Parse actual error message from backend
|
||||
val errorMessage = ErrorParser.parseError(response)
|
||||
ApiResult.Error(errorMessage, 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-email/") {
|
||||
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/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")
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Sign In
|
||||
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/apple-sign-in/") {
|
||||
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 "Apple Sign In failed")
|
||||
}
|
||||
ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// Google Sign In
|
||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/google-sign-in/") {
|
||||
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 "Google Sign In failed")
|
||||
}
|
||||
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch contractors for residence", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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,
|
||||
// Deprecated filter args kept for source compatibility with iOS wrappers.
|
||||
// Backend/OpenAPI currently support: residence, document_type, is_active, expiring_soon, search.
|
||||
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) }
|
||||
isActive?.let { parameter("is_active", it) }
|
||||
expiringSoon?.let { parameter("expiring_soon", 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) }
|
||||
// Backend-supported fields
|
||||
modelNumber?.let { append("model_number", it) }
|
||||
serialNumber?.let { append("serial_number", it) }
|
||||
// Map provider to vendor for backend compatibility
|
||||
provider?.let { append("vendor", it) }
|
||||
purchaseDate?.let { append("purchase_date", it) }
|
||||
// Map endDate to expiry_date for backend compatibility
|
||||
endDate?.let { append("expiry_date", it) }
|
||||
|
||||
// Backend accepts "file" field for single file upload
|
||||
if (fileBytesList != null && fileBytesList.isNotEmpty() && fileNamesList != null && mimeTypesList != null) {
|
||||
// Send first file as "file" (backend only accepts single file)
|
||||
append("file", fileBytesList[0], Headers.build {
|
||||
append(HttpHeaders.ContentType, mimeTypesList.getOrElse(0) { "application/octet-stream" })
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"${fileNamesList.getOrElse(0) { "file_0" }}\"")
|
||||
})
|
||||
} else if (fileBytes != null && fileName != null && mimeType != null) {
|
||||
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,
|
||||
description = description,
|
||||
modelNumber = modelNumber,
|
||||
serialNumber = serialNumber,
|
||||
vendor = provider, // Map provider to vendor
|
||||
purchaseDate = purchaseDate,
|
||||
expiryDate = endDate, // Map endDate to expiryDate
|
||||
residenceId = residenceId
|
||||
)
|
||||
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
|
||||
): ApiResult<Document> {
|
||||
return try {
|
||||
// Backend update handler uses JSON via c.Bind (not multipart)
|
||||
val request = DocumentUpdateRequest(
|
||||
title = title,
|
||||
documentType = documentType,
|
||||
description = description,
|
||||
modelNumber = modelNumber,
|
||||
serialNumber = serialNumber,
|
||||
vendor = provider, // Map provider to vendor
|
||||
purchaseDate = purchaseDate,
|
||||
expiryDate = endDate // Map endDate to expiryDate
|
||||
)
|
||||
val response = 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()) {
|
||||
// Backend returns wrapped response: {message: string, document: DocumentResponse}
|
||||
val wrapper: DocumentActionResponse = response.body()
|
||||
ApiResult.Success(wrapper.document)
|
||||
} 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()) {
|
||||
// Backend returns wrapped response: {message: string, document: DocumentResponse}
|
||||
val wrapper: DocumentActionResponse = response.body()
|
||||
ApiResult.Success(wrapper.document)
|
||||
} else {
|
||||
ApiResult.Error("Failed to deactivate document", 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<Document> {
|
||||
return try {
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = "$baseUrl/documents/$documentId/images/",
|
||||
formData = formData {
|
||||
append("image", imageBytes, Headers.build {
|
||||
append(HttpHeaders.ContentType, mimeType)
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
})
|
||||
caption?.let { append("caption", it) }
|
||||
}
|
||||
) {
|
||||
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 document image"
|
||||
}
|
||||
ApiResult.Error(errorBody, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> {
|
||||
return try {
|
||||
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to delete document image", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses error response from the backend.
|
||||
* The backend returns: {"error": "message"}
|
||||
* Falls back to detail field or field-specific errors if present.
|
||||
*/
|
||||
suspend fun parseError(response: HttpResponse): String {
|
||||
return try {
|
||||
val errorResponse = response.body<ErrorResponse>()
|
||||
|
||||
// Build detailed error message
|
||||
val message = StringBuilder()
|
||||
|
||||
// Primary: use the error field (main error message from backend)
|
||||
message.append(errorResponse.error)
|
||||
|
||||
// Secondary: append detail if present and different from error
|
||||
errorResponse.detail?.let { detail ->
|
||||
if (detail.isNotBlank() && detail != errorResponse.error) {
|
||||
message.append(": $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: try to parse as simple {"error": "message"} map
|
||||
try {
|
||||
val simpleError = response.body<Map<String, String>>()
|
||||
simpleError["error"] ?: simpleError["message"] ?: simpleError["detail"]
|
||||
?: "An error occurred (${response.status.value})"
|
||||
} catch (e2: Exception) {
|
||||
// Last resort: read as plain text
|
||||
try {
|
||||
response.body<String>()
|
||||
} catch (e3: Exception) {
|
||||
"An error occurred (${response.status.value})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.models.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
|
||||
/**
|
||||
* Result type for conditional HTTP requests with ETag support.
|
||||
* Used to efficiently check if data has changed on the server.
|
||||
*/
|
||||
sealed class ConditionalResult<T> {
|
||||
/**
|
||||
* Server returned new data (HTTP 200).
|
||||
* Includes the new ETag for future conditional requests.
|
||||
*/
|
||||
data class Success<T>(val data: T, val etag: String?) : ConditionalResult<T>()
|
||||
|
||||
/**
|
||||
* Data has not changed since the provided ETag (HTTP 304).
|
||||
* Client should continue using cached data.
|
||||
*/
|
||||
class NotModified<T> : ConditionalResult<T>()
|
||||
|
||||
/**
|
||||
* Request failed with an error.
|
||||
*/
|
||||
data class Error<T>(val message: String, val statusCode: Int? = null) : ConditionalResult<T>()
|
||||
}
|
||||
|
||||
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/residences/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/tasks/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/tasks/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 getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/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/contractors/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 getStaticData(token: String? = null): ApiResult<StaticDataResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/static_data/") {
|
||||
// Token is optional - endpoint is public
|
||||
token?.let { header("Authorization", "Token $it") }
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches unified seeded data (all lookups + task templates) with ETag support.
|
||||
*
|
||||
* @param currentETag The ETag from a previous response. If provided and data hasn't changed,
|
||||
* server returns 304 Not Modified.
|
||||
* @param token Optional auth token (endpoint is public).
|
||||
* @return ConditionalResult with data and new ETag, NotModified if unchanged, or Error.
|
||||
*/
|
||||
suspend fun getSeededData(
|
||||
currentETag: String? = null,
|
||||
token: String? = null
|
||||
): ConditionalResult<SeededDataResponse> {
|
||||
return try {
|
||||
val response: HttpResponse = client.get("$baseUrl/static_data/") {
|
||||
// Token is optional - endpoint is public
|
||||
token?.let { header("Authorization", "Token $it") }
|
||||
// Send If-None-Match header for conditional request
|
||||
currentETag?.let { header("If-None-Match", it) }
|
||||
}
|
||||
|
||||
when {
|
||||
response.status == HttpStatusCode.NotModified -> {
|
||||
// Data hasn't changed since provided ETag
|
||||
ConditionalResult.NotModified()
|
||||
}
|
||||
response.status.isSuccess() -> {
|
||||
// Data has changed or first request - get new data and ETag
|
||||
val data: SeededDataResponse = response.body()
|
||||
val newETag = response.headers["ETag"]
|
||||
ConditionalResult.Success(data, newETag)
|
||||
}
|
||||
else -> {
|
||||
ConditionalResult.Error(
|
||||
"Failed to fetch seeded data",
|
||||
response.status.value
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ConditionalResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterDevice(
|
||||
token: String,
|
||||
deviceId: Int
|
||||
): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = client.delete("$baseUrl/notifications/devices/$deviceId/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
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/") {
|
||||
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/") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/notifications/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val listResponse: NotificationListResponse = response.body()
|
||||
ApiResult.Success(listResponse.results)
|
||||
} else {
|
||||
ApiResult.Error("Failed to get notification history", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markNotificationAsRead(
|
||||
token: String,
|
||||
notificationId: Int
|
||||
): ApiResult<MessageResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/notifications/$notificationId/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")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/notifications/mark-all-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")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/notifications/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,265 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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<WithSummaryResponse<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<WithSummaryResponse<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<WithSummaryResponse<String>> {
|
||||
return try {
|
||||
val response = client.delete("$baseUrl/residences/$id/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to delete residence", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSummary(token: String): ApiResult<TotalSummary> {
|
||||
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 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 generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
|
||||
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 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,171 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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? = null): ApiResult<Map<String, UpgradeTriggerData>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
|
||||
// Token is optional - endpoint is public
|
||||
token?.let { header("Authorization", "Token $it") }
|
||||
}
|
||||
|
||||
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(token: String): ApiResult<List<FeatureBenefit>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/subscription/features/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify/process iOS purchase with backend
|
||||
* Used for both new purchases and restore
|
||||
*/
|
||||
suspend fun verifyIOSReceipt(
|
||||
token: String,
|
||||
receiptData: String,
|
||||
transactionId: String
|
||||
): ApiResult<VerificationResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/subscription/purchase/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf(
|
||||
"platform" to "ios",
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify/process Android purchase with backend
|
||||
* Used for both new purchases and restore
|
||||
*/
|
||||
suspend fun verifyAndroidPurchase(
|
||||
token: String,
|
||||
purchaseToken: String,
|
||||
productId: String
|
||||
): ApiResult<VerificationResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/subscription/purchase/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf(
|
||||
"platform" to "android",
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore subscription from iOS/Android receipt
|
||||
*/
|
||||
suspend fun restoreSubscription(
|
||||
token: String,
|
||||
platform: String,
|
||||
receiptData: String? = null,
|
||||
purchaseToken: String? = null,
|
||||
productId: String? = null
|
||||
): ApiResult<VerificationResponse> {
|
||||
return try {
|
||||
val body = mutableMapOf<String, String>("platform" to platform)
|
||||
if (platform == "ios" && receiptData != null) {
|
||||
body["receipt_data"] = receiptData
|
||||
} else if (platform == "android" && purchaseToken != null) {
|
||||
body["purchase_token"] = purchaseToken
|
||||
productId?.let { body["product_id"] = it }
|
||||
}
|
||||
|
||||
val response = client.post("$baseUrl/subscription/restore/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to restore subscription", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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<WithSummaryResponse<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<WithSummaryResponse<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<WithSummaryResponse<String>> {
|
||||
return try {
|
||||
val response = client.delete("$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 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.
|
||||
* Returns TaskWithSummaryResponse to update dashboard stats in one call.
|
||||
*/
|
||||
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<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")
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods for common task actions
|
||||
// These use dedicated POST endpoints for state changes
|
||||
|
||||
suspend fun cancelTask(token: String, id: Int): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return postTaskAction(token, id, "cancel")
|
||||
}
|
||||
|
||||
suspend fun uncancelTask(token: String, id: Int): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return postTaskAction(token, id, "uncancel")
|
||||
}
|
||||
|
||||
suspend fun markInProgress(token: String, id: Int): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return patchTask(token, id, TaskPatchRequest(inProgress = true))
|
||||
}
|
||||
|
||||
suspend fun clearInProgress(token: String, id: Int): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return patchTask(token, id, TaskPatchRequest(inProgress = false))
|
||||
}
|
||||
|
||||
suspend fun archiveTask(token: String, id: Int): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return postTaskAction(token, id, "archive")
|
||||
}
|
||||
|
||||
suspend fun unarchiveTask(token: String, id: Int): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return postTaskAction(token, id, "unarchive")
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for POST task action endpoints (cancel, uncancel, archive, unarchive)
|
||||
*/
|
||||
private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/tasks/$id/$action/") {
|
||||
header("Authorization", "Token $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val data = response.body<WithSummaryResponse<TaskResponse>>()
|
||||
ApiResult.Success(data)
|
||||
}
|
||||
HttpStatusCode.NotFound -> ApiResult.Error("Task not found", 404)
|
||||
HttpStatusCode.Forbidden -> ApiResult.Error("Access denied", 403)
|
||||
HttpStatusCode.BadRequest -> {
|
||||
val errorBody = response.body<String>()
|
||||
ApiResult.Error(errorBody, 400)
|
||||
}
|
||||
else -> ApiResult.Error("Task $action failed: ${response.status}", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,140 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.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.*
|
||||
|
||||
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<WithSummaryResponse<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<WithSummaryResponse<String>> {
|
||||
return try {
|
||||
val response = client.delete("$baseUrl/task-completions/$id/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} 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<WithSummaryResponse<TaskCompletionResponse>> {
|
||||
return try {
|
||||
val response = client.submitFormWithBinaryData(
|
||||
url = "$baseUrl/task-completions/",
|
||||
formData = 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,
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, "image/jpeg")
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
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,124 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
/**
|
||||
* API client for task templates.
|
||||
* Task templates are public (no auth required) and used for autocomplete when adding tasks.
|
||||
*/
|
||||
class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
/**
|
||||
* Get all task templates as a flat list
|
||||
*/
|
||||
suspend fun getTemplates(): ApiResult<List<TaskTemplate>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/")
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch task templates", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all task templates grouped by category
|
||||
*/
|
||||
suspend fun getTemplatesGrouped(): ApiResult<TaskTemplatesGroupedResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/grouped/")
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch grouped task templates", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search task templates by query string
|
||||
*/
|
||||
suspend fun searchTemplates(query: String): ApiResult<List<TaskTemplate>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/search/") {
|
||||
parameter("q", query)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to search task templates", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category ID
|
||||
*/
|
||||
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/by-category/$categoryId/")
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch templates by category", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates filtered by climate region.
|
||||
* Accepts either a state abbreviation or ZIP code — backend resolves to climate zone.
|
||||
*/
|
||||
suspend fun getTemplatesByRegion(state: String? = null, zip: String? = null): ApiResult<List<TaskTemplate>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/by-region/") {
|
||||
state?.let { parameter("state", it) }
|
||||
zip?.let { parameter("zip", it) }
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch regional templates", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single template by ID
|
||||
*/
|
||||
suspend fun getTemplate(id: Int): ApiResult<TaskTemplate> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/templates/$id/")
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Template not found", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
|
||||
/**
|
||||
* Platform-specific composable that handles contractor import flow.
|
||||
* On Android, shows dialogs to confirm and execute import.
|
||||
* On other platforms, this is a no-op.
|
||||
*
|
||||
* @param pendingContractorImportUri Platform-specific URI object (e.g., android.net.Uri)
|
||||
* @param onClearContractorImport Called when import flow is complete
|
||||
* @param onImportSuccess Called when a contractor is successfully imported
|
||||
*/
|
||||
@Composable
|
||||
expect fun ContractorImportHandler(
|
||||
pendingContractorImportUri: Any?,
|
||||
onClearContractorImport: () -> Unit,
|
||||
onImportSuccess: (Contractor) -> Unit = {}
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
|
||||
/**
|
||||
* Returns a function that can be called to share a contractor.
|
||||
* The returned function will open the native share sheet with a .honeydue file.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberShareContractor(): (Contractor) -> Unit
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Types of haptic feedback available.
|
||||
*/
|
||||
enum class HapticFeedbackType {
|
||||
/** Light feedback - for selections, toggles */
|
||||
Light,
|
||||
/** Medium feedback - for confirmations */
|
||||
Medium,
|
||||
/** Heavy feedback - for important actions */
|
||||
Heavy,
|
||||
/** Selection changed feedback */
|
||||
Selection,
|
||||
/** Success feedback */
|
||||
Success,
|
||||
/** Warning feedback */
|
||||
Warning,
|
||||
/** Error feedback */
|
||||
Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for performing haptic feedback.
|
||||
*/
|
||||
interface HapticFeedbackPerformer {
|
||||
fun perform(type: HapticFeedbackType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a haptic feedback performer for the current platform.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberHapticFeedback(): HapticFeedbackPerformer
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
/**
|
||||
* Converts ImageData bytes to an ImageBitmap for display.
|
||||
* Returns null if conversion fails.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberImageBitmap(imageData: ImageData): ImageBitmap?
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.tt.honeyDue.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,15 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Platform-specific upgrade screen composable.
|
||||
* On Android, wires BillingManager for Google Play purchases.
|
||||
* On iOS, purchase flow is handled in Swift via StoreKitManager.
|
||||
* On other platforms, shows the common UpgradeScreen with backend-only restore.
|
||||
*/
|
||||
@Composable
|
||||
expect fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
|
||||
/**
|
||||
* Platform-specific composable that handles residence import flow.
|
||||
* On Android, shows dialogs to confirm and execute import.
|
||||
* On other platforms, this is a no-op.
|
||||
*
|
||||
* @param pendingResidenceImportUri Platform-specific URI object (e.g., android.net.Uri)
|
||||
* @param onClearResidenceImport Called when import flow is complete
|
||||
* @param onImportSuccess Called when a residence is successfully joined
|
||||
*/
|
||||
@Composable
|
||||
expect fun ResidenceImportHandler(
|
||||
pendingResidenceImportUri: Any?,
|
||||
onClearResidenceImport: () -> Unit,
|
||||
onImportSuccess: (JoinResidenceResponse) -> Unit = {}
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.Residence
|
||||
|
||||
/**
|
||||
* State holder for residence sharing operation.
|
||||
*/
|
||||
data class ResidenceSharingState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns a pair of state and a function to share a residence.
|
||||
* The function will:
|
||||
* 1. Call the backend to generate a share code
|
||||
* 2. Create a .honeydue file with the share package
|
||||
* 3. Open the native share sheet
|
||||
*
|
||||
* @return Pair of (state, shareFunction)
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit>
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.tt.honeyDue.repository
|
||||
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.*
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Singleton repository for managing lookup data across the entire app.
|
||||
*
|
||||
* @deprecated Use DataManager directly. This class is kept for backwards compatibility
|
||||
* and simply delegates to DataManager.
|
||||
*/
|
||||
object LookupsRepository {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
// Delegate to DataManager
|
||||
val residenceTypes: StateFlow<List<ResidenceType>> = DataManager.residenceTypes
|
||||
val taskFrequencies: StateFlow<List<TaskFrequency>> = DataManager.taskFrequencies
|
||||
val taskPriorities: StateFlow<List<TaskPriority>> = DataManager.taskPriorities
|
||||
val taskCategories: StateFlow<List<TaskCategory>> = DataManager.taskCategories
|
||||
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = DataManager.contractorSpecialties
|
||||
val isInitialized: StateFlow<Boolean> = DataManager.lookupsInitialized
|
||||
|
||||
/**
|
||||
* Load all lookups from the API via DataManager.
|
||||
* This should be called once when the user logs in.
|
||||
*/
|
||||
fun initialize() {
|
||||
// DataManager handles initialization via APILayer.initializeLookups()
|
||||
if (DataManager.lookupsInitialized.value) {
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
APILayer.initializeLookups()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data via DataManager.
|
||||
* This should be called when the user logs out.
|
||||
*/
|
||||
fun clear() {
|
||||
// DataManager.clear() is called by APILayer.logout()
|
||||
// This method is kept for backwards compatibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh all lookups from the API.
|
||||
*/
|
||||
fun refresh() {
|
||||
scope.launch {
|
||||
APILayer.initializeLookups()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.tt.honeyDue.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()
|
||||
|
||||
/**
|
||||
* Check if tasks need refresh due to widget interactions.
|
||||
* Returns true if data was modified externally (e.g., by a widget).
|
||||
*/
|
||||
fun areTasksDirty(): Boolean
|
||||
|
||||
/**
|
||||
* Mark tasks as dirty (needs refresh).
|
||||
* Called when widget modifies task data.
|
||||
*/
|
||||
fun markTasksDirty()
|
||||
|
||||
/**
|
||||
* Clear the dirty flag after tasks have been refreshed.
|
||||
*/
|
||||
fun clearDirtyFlag()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import com.tt.honeyDue.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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tasks need refresh due to widget interactions.
|
||||
*/
|
||||
fun areTasksDirty(): Boolean {
|
||||
ensureInitialized()
|
||||
return cacheManager?.areTasksDirty() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark tasks as dirty (needs refresh).
|
||||
* Called when widget modifies task data.
|
||||
*/
|
||||
fun markTasksDirty() {
|
||||
ensureInitialized()
|
||||
cacheManager?.markTasksDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the dirty flag after tasks have been refreshed.
|
||||
*/
|
||||
fun clearDirtyFlag() {
|
||||
ensureInitialized()
|
||||
cacheManager?.clearDirtyFlag()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.tt.honeyDue.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.tt.honeyDue.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,56 @@
|
||||
package com.tt.honeyDue.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()
|
||||
// Always read from storage to avoid stale cache issues
|
||||
// (DataManager.setAuthToken updates tokenManager directly, bypassing our cachedToken)
|
||||
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,515 @@
|
||||
package com.tt.honeyDue.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 honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||
import com.tt.honeyDue.viewmodel.ResidenceViewModel
|
||||
import com.tt.honeyDue.models.ContractorCreateRequest
|
||||
import com.tt.honeyDue.models.ContractorUpdateRequest
|
||||
import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.repository.LookupsRepository
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddContractorDialog(
|
||||
contractorId: Int? = null,
|
||||
onDismiss: () -> Unit,
|
||||
onContractorSaved: () -> Unit,
|
||||
viewModel: ContractorViewModel = viewModel { ContractorViewModel() },
|
||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
val createState by viewModel.createState.collectAsState()
|
||||
val updateState by viewModel.updateState.collectAsState()
|
||||
val contractorDetailState by viewModel.contractorDetailState.collectAsState()
|
||||
val residencesState by residenceViewModel.residencesState.collectAsState()
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var company by remember { mutableStateOf("") }
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var website by remember { mutableStateOf("") }
|
||||
var streetAddress by remember { mutableStateOf("") }
|
||||
var city by remember { mutableStateOf("") }
|
||||
var stateProvince by remember { mutableStateOf("") }
|
||||
var postalCode by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var isFavorite by remember { mutableStateOf(false) }
|
||||
var selectedResidence by remember { mutableStateOf<Residence?>(null) }
|
||||
var selectedSpecialtyIds by remember { mutableStateOf<List<Int>>(emptyList()) }
|
||||
|
||||
var expandedSpecialtyMenu by remember { mutableStateOf(false) }
|
||||
var expandedResidenceMenu by remember { mutableStateOf(false) }
|
||||
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
|
||||
|
||||
// Track screen view and load residences for picker
|
||||
LaunchedEffect(Unit) {
|
||||
if (contractorId == null) {
|
||||
PostHogAnalytics.screen(AnalyticsEvents.NEW_CONTRACTOR_SCREEN_SHOWN)
|
||||
}
|
||||
residenceViewModel.loadResidences()
|
||||
}
|
||||
|
||||
// Load existing contractor data if editing
|
||||
LaunchedEffect(contractorId) {
|
||||
if (contractorId != null) {
|
||||
viewModel.loadContractorDetail(contractorId)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(contractorDetailState, residencesState) {
|
||||
if (contractorDetailState is ApiResult.Success) {
|
||||
val contractor = (contractorDetailState as ApiResult.Success).data
|
||||
name = contractor.name
|
||||
company = contractor.company ?: ""
|
||||
phone = contractor.phone ?: ""
|
||||
email = contractor.email ?: ""
|
||||
website = contractor.website ?: ""
|
||||
streetAddress = contractor.streetAddress ?: ""
|
||||
city = contractor.city ?: ""
|
||||
stateProvince = contractor.stateProvince ?: ""
|
||||
postalCode = contractor.postalCode ?: ""
|
||||
notes = contractor.notes ?: ""
|
||||
isFavorite = contractor.isFavorite
|
||||
selectedSpecialtyIds = contractor.specialties.map { it.id }
|
||||
|
||||
// Set selected residence if contractor has one
|
||||
if (contractor.residenceId != null && residencesState is ApiResult.Success) {
|
||||
val residences = (residencesState as ApiResult.Success<List<Residence>>).data
|
||||
selectedResidence = residences.find { it.id == contractor.residenceId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(createState) {
|
||||
if (createState is ApiResult.Success) {
|
||||
PostHogAnalytics.capture(AnalyticsEvents.CONTRACTOR_CREATED)
|
||||
onContractorSaved()
|
||||
viewModel.resetCreateState()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(updateState) {
|
||||
if (updateState is ApiResult.Success) {
|
||||
onContractorSaved()
|
||||
viewModel.resetUpdateState()
|
||||
}
|
||||
}
|
||||
|
||||
val dialogTitle = if (contractorId == null)
|
||||
stringResource(Res.string.contractors_form_add_title)
|
||||
else
|
||||
stringResource(Res.string.contractors_form_edit_title)
|
||||
val personalNoResidence = stringResource(Res.string.contractors_form_personal_no_residence)
|
||||
val cancelText = stringResource(Res.string.common_cancel)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(0.95f),
|
||||
title = {
|
||||
Text(
|
||||
dialogTitle,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Basic Information Section
|
||||
Text(
|
||||
stringResource(Res.string.contractors_form_basic_info),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_name_required)) },
|
||||
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(stringResource(Res.string.contractors_form_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)
|
||||
)
|
||||
)
|
||||
|
||||
// Residence Picker (Optional)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedResidenceMenu,
|
||||
onExpandedChange = { expandedResidenceMenu = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedResidence?.name ?: personalNoResidence,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(Res.string.contractors_form_residence_optional)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedResidenceMenu) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
leadingIcon = { Icon(Icons.Default.Home, null) },
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color(0xFF3B82F6),
|
||||
unfocusedBorderColor = Color(0xFFE5E7EB)
|
||||
)
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedResidenceMenu,
|
||||
onDismissRequest = { expandedResidenceMenu = false }
|
||||
) {
|
||||
// Option for no residence (personal contractor)
|
||||
DropdownMenuItem(
|
||||
text = { Text(personalNoResidence) },
|
||||
onClick = {
|
||||
selectedResidence = null
|
||||
expandedResidenceMenu = false
|
||||
}
|
||||
)
|
||||
|
||||
// List residences if loaded
|
||||
if (residencesState is ApiResult.Success) {
|
||||
val residences = (residencesState as ApiResult.Success<List<Residence>>).data
|
||||
residences.forEach { residence ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(residence.name) },
|
||||
onClick = {
|
||||
selectedResidence = residence
|
||||
expandedResidenceMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
if (selectedResidence == null) stringResource(Res.string.contractors_form_personal_visibility)
|
||||
else stringResource(Res.string.contractors_form_shared_visibility, selectedResidence?.name ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF6B7280)
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
// Contact Information Section
|
||||
Text(
|
||||
stringResource(Res.string.contractors_form_contact_info),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_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(stringResource(Res.string.contractors_form_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 = website,
|
||||
onValueChange = { website = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_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))
|
||||
|
||||
// Specialties Section
|
||||
Text(
|
||||
stringResource(Res.string.contractors_form_specialties),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
)
|
||||
|
||||
// Multi-select specialties using chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
contractorSpecialties.forEach { specialty ->
|
||||
FilterChip(
|
||||
selected = selectedSpecialtyIds.contains(specialty.id),
|
||||
onClick = {
|
||||
selectedSpecialtyIds = if (selectedSpecialtyIds.contains(specialty.id)) {
|
||||
selectedSpecialtyIds - specialty.id
|
||||
} else {
|
||||
selectedSpecialtyIds + specialty.id
|
||||
}
|
||||
},
|
||||
label = { Text(specialty.name) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = Color(0xFF3B82F6),
|
||||
selectedLabelColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
// Address Section
|
||||
Text(
|
||||
stringResource(Res.string.contractors_form_address_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = streetAddress,
|
||||
onValueChange = { streetAddress = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_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(stringResource(Res.string.contractors_form_city)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color(0xFF3B82F6),
|
||||
unfocusedBorderColor = Color(0xFFE5E7EB)
|
||||
)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = stateProvince,
|
||||
onValueChange = { stateProvince = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_state)) },
|
||||
modifier = Modifier.weight(0.5f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color(0xFF3B82F6),
|
||||
unfocusedBorderColor = Color(0xFFE5E7EB)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postalCode,
|
||||
onValueChange = { postalCode = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_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(
|
||||
stringResource(Res.string.contractors_form_notes_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text(stringResource(Res.string.contractors_form_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(stringResource(Res.string.contractors_form_mark_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,
|
||||
residenceId = selectedResidence?.id,
|
||||
company = company.takeIf { it.isNotBlank() },
|
||||
phone = phone.takeIf { it.isNotBlank() },
|
||||
email = email.takeIf { it.isNotBlank() },
|
||||
website = website.takeIf { it.isNotBlank() },
|
||||
streetAddress = streetAddress.takeIf { it.isNotBlank() },
|
||||
city = city.takeIf { it.isNotBlank() },
|
||||
stateProvince = stateProvince.takeIf { it.isNotBlank() },
|
||||
postalCode = postalCode.takeIf { it.isNotBlank() },
|
||||
isFavorite = isFavorite,
|
||||
notes = notes.takeIf { it.isNotBlank() },
|
||||
specialtyIds = selectedSpecialtyIds.takeIf { it.isNotEmpty() }
|
||||
)
|
||||
)
|
||||
} else {
|
||||
viewModel.updateContractor(
|
||||
contractorId,
|
||||
ContractorUpdateRequest(
|
||||
name = name,
|
||||
residenceId = selectedResidence?.id,
|
||||
company = company.takeIf { it.isNotBlank() },
|
||||
phone = phone.takeIf { it.isNotBlank() },
|
||||
email = email.takeIf { it.isNotBlank() },
|
||||
website = website.takeIf { it.isNotBlank() },
|
||||
streetAddress = streetAddress.takeIf { it.isNotBlank() },
|
||||
city = city.takeIf { it.isNotBlank() },
|
||||
stateProvince = stateProvince.takeIf { it.isNotBlank() },
|
||||
postalCode = postalCode.takeIf { it.isNotBlank() },
|
||||
isFavorite = isFavorite,
|
||||
notes = notes.takeIf { it.isNotBlank() },
|
||||
specialtyIds = selectedSpecialtyIds.takeIf { it.isNotEmpty() }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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) stringResource(Res.string.contractors_form_add_button) else stringResource(Res.string.contractors_form_save_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(cancelText, color = Color(0xFF6B7280))
|
||||
}
|
||||
},
|
||||
containerColor = Color.White,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.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.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.MyResidencesResponse
|
||||
import com.tt.honeyDue.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,500 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.ChevronRight
|
||||
import androidx.compose.material.icons.filled.List
|
||||
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 com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.repository.LookupsRepository
|
||||
import com.tt.honeyDue.models.MyResidencesResponse
|
||||
import com.tt.honeyDue.models.TaskCategory
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskFrequency
|
||||
import com.tt.honeyDue.models.TaskPriority
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@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) }
|
||||
|
||||
// Template suggestions state
|
||||
var showTemplatesBrowser by remember { mutableStateOf(false) }
|
||||
var showSuggestions by remember { mutableStateOf(false) }
|
||||
|
||||
// Get data from LookupsRepository and DataManager
|
||||
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
|
||||
val priorities by LookupsRepository.taskPriorities.collectAsState()
|
||||
val categories by LookupsRepository.taskCategories.collectAsState()
|
||||
val allTemplates by DataManager.taskTemplates.collectAsState()
|
||||
|
||||
// Search templates locally
|
||||
val filteredSuggestions = remember(title, allTemplates) {
|
||||
if (title.length >= 2) {
|
||||
DataManager.searchTaskTemplates(title)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to apply a task template
|
||||
fun selectTaskTemplate(template: TaskTemplate) {
|
||||
title = template.title
|
||||
description = template.description
|
||||
|
||||
// Auto-select matching category by ID or name
|
||||
template.categoryId?.let { catId ->
|
||||
categories.find { it.id == catId }?.let {
|
||||
category = it
|
||||
}
|
||||
} ?: template.category?.let { cat ->
|
||||
categories.find { it.name.equals(cat.name, ignoreCase = true) }?.let {
|
||||
category = it
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select matching frequency by ID or name
|
||||
template.frequencyId?.let { freqId ->
|
||||
frequencies.find { it.id == freqId }?.let {
|
||||
frequency = it
|
||||
}
|
||||
} ?: template.frequency?.let { freq ->
|
||||
frequencies.find { it.name.equals(freq.name, ignoreCase = true) }?.let {
|
||||
frequency = it
|
||||
}
|
||||
}
|
||||
|
||||
showSuggestions = false
|
||||
}
|
||||
|
||||
// Track screen view
|
||||
LaunchedEffect(Unit) {
|
||||
PostHogAnalytics.screen(AnalyticsEvents.NEW_TASK_SCREEN_SHOWN)
|
||||
}
|
||||
|
||||
// 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(stringResource(Res.string.tasks_add_new)) },
|
||||
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(stringResource(Res.string.tasks_property_required)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = residenceError,
|
||||
supportingText = if (residenceError) {
|
||||
{ Text(stringResource(Res.string.tasks_property_error)) }
|
||||
} 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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browse Templates Button
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showTemplatesBrowser = true },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.List,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.tasks_browse_templates),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.tasks_common_tasks, allTemplates.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
// Title with inline suggestions
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = {
|
||||
title = it
|
||||
titleError = false
|
||||
showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty()
|
||||
},
|
||||
label = { Text(stringResource(Res.string.tasks_title_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = titleError,
|
||||
supportingText = if (titleError) {
|
||||
{ Text(stringResource(Res.string.tasks_title_error)) }
|
||||
} else null,
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Inline suggestions dropdown
|
||||
if (showSuggestions && filteredSuggestions.isNotEmpty()) {
|
||||
TaskSuggestionDropdown(
|
||||
suggestions = filteredSuggestions,
|
||||
onSelect = { template ->
|
||||
selectTaskTemplate(template)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text(stringResource(Res.string.tasks_description_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
)
|
||||
|
||||
// Category
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showCategoryDropdown,
|
||||
onExpandedChange = { showCategoryDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = categories.find { it == category }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text(stringResource(Res.string.tasks_category_required)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = categoryError,
|
||||
supportingText = if (categoryError) {
|
||||
{ Text(stringResource(Res.string.tasks_category_error)) }
|
||||
} 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(stringResource(Res.string.tasks_frequency_label)) },
|
||||
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 not "Custom"
|
||||
if (!freq.name.equals("Custom", ignoreCase = true)) {
|
||||
intervalDays = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Interval Days (only for "Custom" frequency)
|
||||
if (frequency.name.equals("Custom", ignoreCase = true)) {
|
||||
OutlinedTextField(
|
||||
value = intervalDays,
|
||||
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
|
||||
label = { Text(stringResource(Res.string.tasks_interval_days)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
// Due Date
|
||||
OutlinedTextField(
|
||||
value = dueDate,
|
||||
onValueChange = {
|
||||
dueDate = it
|
||||
dueDateError = false
|
||||
},
|
||||
label = { Text(stringResource(Res.string.tasks_due_date_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = dueDateError,
|
||||
supportingText = if (dueDateError) {
|
||||
{ Text(stringResource(Res.string.tasks_due_date_format_error)) }
|
||||
} else {
|
||||
{ Text(stringResource(Res.string.tasks_due_date_format)) }
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Priority
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showPriorityDropdown,
|
||||
onExpandedChange = { showPriorityDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = priorities.find { it.name == priority.name }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text(stringResource(Res.string.tasks_priority_label)) },
|
||||
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(stringResource(Res.string.tasks_estimated_cost_label)) },
|
||||
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,
|
||||
customIntervalDays = if (frequency.name.equals("Custom", ignoreCase = true) && intervalDays.isNotBlank()) {
|
||||
intervalDays.toIntOrNull()
|
||||
} else null,
|
||||
priorityId = if (priority.id > 0) priority.id else null,
|
||||
inProgress = false,
|
||||
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(stringResource(Res.string.tasks_create))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Templates browser sheet
|
||||
if (showTemplatesBrowser) {
|
||||
TaskTemplatesBrowserSheet(
|
||||
onDismiss = { showTemplatesBrowser = false },
|
||||
onSelect = { template ->
|
||||
selectTaskTemplate(template)
|
||||
showTemplatesBrowser = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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,125 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.network.NetworkHeaders
|
||||
import coil3.network.httpHeaders
|
||||
import com.tt.honeyDue.network.ApiClient
|
||||
import com.tt.honeyDue.storage.TokenStorage
|
||||
|
||||
/**
|
||||
* A Compose component that loads images from authenticated API endpoints.
|
||||
* Use this for media that requires auth token (documents, completions, etc.)
|
||||
*
|
||||
* Example usage:
|
||||
* ```kotlin
|
||||
* AuthenticatedImage(
|
||||
* mediaUrl = document.mediaUrl,
|
||||
* contentDescription = "Document image"
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
@Composable
|
||||
fun AuthenticatedImage(
|
||||
mediaUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
placeholder: @Composable () -> Unit = { DefaultPlaceholder() },
|
||||
errorContent: @Composable () -> Unit = { DefaultErrorContent() }
|
||||
) {
|
||||
if (mediaUrl.isNullOrEmpty()) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
errorContent()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val baseUrl = ApiClient.getMediaBaseUrl()
|
||||
val token = TokenStorage.getToken()
|
||||
val fullUrl = baseUrl + mediaUrl
|
||||
val context = LocalPlatformContext.current
|
||||
|
||||
val imageRequest = remember(fullUrl, token) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(fullUrl)
|
||||
.apply {
|
||||
if (token != null) {
|
||||
httpHeaders(
|
||||
NetworkHeaders.Builder()
|
||||
.set("Authorization", "Token $token")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
SubcomposeAsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale
|
||||
) {
|
||||
when (painter.state) {
|
||||
is AsyncImagePainter.State.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
errorContent()
|
||||
}
|
||||
}
|
||||
else -> SubcomposeAsyncImageContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultPlaceholder() {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultErrorContent() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = "Failed to load",
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Failed to load",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.Close
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.platform.ImageData
|
||||
import com.tt.honeyDue.platform.rememberImagePicker
|
||||
import com.tt.honeyDue.platform.rememberCameraPicker
|
||||
import com.tt.honeyDue.platform.HapticFeedbackType
|
||||
import com.tt.honeyDue.platform.rememberHapticFeedback
|
||||
import com.tt.honeyDue.platform.rememberImageBitmap
|
||||
import kotlinx.datetime.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private const val MAX_IMAGES = 5
|
||||
|
||||
@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()
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
|
||||
// Load contractors when dialog opens
|
||||
LaunchedEffect(Unit) {
|
||||
contractorViewModel.loadContractors()
|
||||
}
|
||||
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
// Add new images up to the max limit
|
||||
val newTotal = (selectedImages + images).take(MAX_IMAGES)
|
||||
selectedImages = newTotal
|
||||
}
|
||||
|
||||
val cameraPicker = rememberCameraPicker { image ->
|
||||
if (selectedImages.size < MAX_IMAGES) {
|
||||
selectedImages = selectedImages + image
|
||||
}
|
||||
}
|
||||
|
||||
val noneManualEntry = stringResource(Res.string.completions_none_manual)
|
||||
val cancelText = stringResource(Res.string.common_cancel)
|
||||
val removeImageDesc = stringResource(Res.string.completions_remove_image)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(Res.string.completions_complete_task_title, 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(stringResource(Res.string.completions_select_contractor)) },
|
||||
placeholder = { Text(stringResource(Res.string.completions_choose_contractor_placeholder)) },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.ArrowDropDown, stringResource(Res.string.completions_expand))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
colors = OutlinedTextFieldDefaults.colors()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = showContractorDropdown,
|
||||
onDismissRequest = { showContractorDropdown = false }
|
||||
) {
|
||||
// "None" option to clear selection
|
||||
DropdownMenuItem(
|
||||
text = { Text(noneManualEntry) },
|
||||
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(stringResource(Res.string.completions_loading_contractors)) },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.completions_error_loading_contractors)) },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = completedByName,
|
||||
onValueChange = { completedByName = it },
|
||||
label = { Text(stringResource(Res.string.completions_completed_by_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
|
||||
enabled = selectedContractorId == null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = actualCost,
|
||||
onValueChange = { actualCost = it },
|
||||
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
prefix = { Text("$") }
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text(stringResource(Res.string.completions_notes_optional)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
// Quality Rating Section - Interactive Stars
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_quality_rating),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Text(
|
||||
text = "$rating / 5",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Interactive Star Rating
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
(1..5).forEach { star ->
|
||||
val isSelected = star <= rating
|
||||
val starColor by animateColorAsState(
|
||||
targetValue = if (isSelected) Color(0xFFFFD700) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "starColor"
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Selection)
|
||||
rating = star
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = "$star stars",
|
||||
tint = starColor,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image upload section with thumbnails
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Photo buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
cameraPicker()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < MAX_IMAGES
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(Res.string.completions_camera))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
imagePicker()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < MAX_IMAGES
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(Res.string.completions_library))
|
||||
}
|
||||
}
|
||||
|
||||
// Image thumbnails with preview
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
selectedImages.forEachIndexed { index, imageData ->
|
||||
ImageThumbnail(
|
||||
imageData = imageData,
|
||||
onRemove = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
selectedImages = selectedImages.toMutableList().also {
|
||||
it.removeAt(index)
|
||||
}
|
||||
},
|
||||
removeContentDescription = removeImageDesc
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper text
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_add_photos_helper),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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(stringResource(Res.string.completions_complete_button))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(cancelText)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to get current date/time in ISO format
|
||||
private fun getCurrentDateTime(): String {
|
||||
return kotlinx.datetime.LocalDate.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Image thumbnail with remove button for displaying selected images.
|
||||
*/
|
||||
@Composable
|
||||
private fun ImageThumbnail(
|
||||
imageData: ImageData,
|
||||
onRemove: () -> Unit,
|
||||
removeContentDescription: String
|
||||
) {
|
||||
val imageBitmap = rememberImageBitmap(imageData)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = imageData.fileName,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Fallback placeholder
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.error)
|
||||
.clickable(onClick = onRemove),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = removeContentDescription,
|
||||
tint = MaterialTheme.colorScheme.onError,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.models.SharedContractor
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Dialog shown when a user attempts to import a contractor from a .honeydue file.
|
||||
* Shows contractor details and asks for confirmation.
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportConfirmDialog(
|
||||
sharedContractor: SharedContractor,
|
||||
isImporting: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!isImporting) onDismiss() },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_import_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_import_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Contractor details
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = sharedContractor.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
sharedContractor.company?.let { company ->
|
||||
Text(
|
||||
text = company,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (sharedContractor.specialtyNames.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = sharedContractor.specialtyNames.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
sharedContractor.exportedBy?.let { exportedBy ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_shared_by, exportedBy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = !isImporting,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isImporting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.common_importing))
|
||||
} else {
|
||||
Text(stringResource(Res.string.common_import))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isImporting
|
||||
) {
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown after a contractor import attempt succeeds.
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportSuccessDialog(
|
||||
contractorName: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_import_success),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_import_success_message, contractorName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown after a contractor import attempt fails.
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportErrorDialog(
|
||||
errorMessage: String,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_import_failed),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
if (onRetry != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRetry()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(Res.string.common_try_again))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (onRetry != null) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.tt.honeyDue.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,126 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
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 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
|
||||
when (val result = APILayer.joinWithCode(shareCode.text)) {
|
||||
is ApiResult.Success -> {
|
||||
isJoining = false
|
||||
onJoined()
|
||||
onDismiss()
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
error = result.message
|
||||
isJoining = false
|
||||
}
|
||||
else -> {
|
||||
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,364 @@
|
||||
package com.tt.honeyDue.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.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
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.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tt.honeyDue.models.ResidenceUser
|
||||
import com.tt.honeyDue.models.ResidenceShareCode
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ManageUsersDialog(
|
||||
residenceId: Int,
|
||||
residenceName: String,
|
||||
isPrimaryOwner: Boolean,
|
||||
residenceOwnerId: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onUserRemoved: () -> Unit = {},
|
||||
onSharePackage: () -> Unit = {}
|
||||
) {
|
||||
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
|
||||
val ownerId = residenceOwnerId
|
||||
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 scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// Load users
|
||||
LaunchedEffect(residenceId) {
|
||||
// Clear share code on open so it's always blank
|
||||
shareCode = null
|
||||
|
||||
when (val result = APILayer.getResidenceUsers(residenceId)) {
|
||||
is ApiResult.Success -> {
|
||||
users = result.data
|
||||
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
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Invite Others")
|
||||
}
|
||||
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 sections (primary owner only)
|
||||
if (isPrimaryOwner) {
|
||||
// Easy Share section (on top - recommended)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Easy Share",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onSharePackage() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Share, "Share", modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Send Invite Link")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Send a .honeydue file via Messages, Email, or share. They just tap to join.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Divider with "or"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "or",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Share Code section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Share Code",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (shareCode != null) {
|
||||
Text(
|
||||
text = shareCode!!.code,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = 4.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(shareCode!!.code))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "No active code",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isGeneratingCode = true
|
||||
when (val result = APILayer.generateShareCode(residenceId)) {
|
||||
is ApiResult.Success -> {
|
||||
shareCode = result.data.shareCode
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
error = result.message
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
isGeneratingCode = false
|
||||
}
|
||||
},
|
||||
enabled = !isGeneratingCode,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (isGeneratingCode) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Refresh, "Generate", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(if (shareCode != null) "Generate New Code" else "Generate Code")
|
||||
}
|
||||
|
||||
if (shareCode != null) {
|
||||
Text(
|
||||
text = "Share this 6-character code. They can enter it in the app to join.",
|
||||
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(200.dp)
|
||||
) {
|
||||
items(users) { user ->
|
||||
UserListItem(
|
||||
user = user,
|
||||
isOwner = user.id == ownerId,
|
||||
isPrimaryOwner = isPrimaryOwner,
|
||||
onRemove = {
|
||||
scope.launch {
|
||||
when (APILayer.removeUser(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,229 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.Close
|
||||
import androidx.compose.material.icons.filled.ZoomIn
|
||||
import androidx.compose.material.icons.filled.ZoomOut
|
||||
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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
|
||||
/**
|
||||
* Full-screen photo viewer with swipeable gallery.
|
||||
* Matches iOS PhotoViewerSheet functionality.
|
||||
*
|
||||
* Features:
|
||||
* - Horizontal paging between images
|
||||
* - Pinch to zoom
|
||||
* - Double-tap to zoom
|
||||
* - Page indicator dots
|
||||
* - Close button
|
||||
* - Image counter
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PhotoViewerScreen(
|
||||
imageUrls: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(0, (imageUrls.size - 1).coerceAtLeast(0)),
|
||||
pageCount = { imageUrls.size }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// Photo Pager
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
ZoomableImage(
|
||||
imageUrl = imageUrls[page],
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
// Top Bar with close button and counter
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(AppSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Close Button
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Image Counter
|
||||
if (imageUrls.size > 1) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.Black.copy(alpha = 0.5f)
|
||||
) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imageUrls.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page Indicator Dots (bottom)
|
||||
if (imageUrls.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = AppSpacing.xl),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
repeat(imageUrls.size) { index ->
|
||||
val isSelected = pagerState.currentPage == index
|
||||
val size by animateFloatAsState(
|
||||
targetValue = if (isSelected) 10f else 6f,
|
||||
label = "dotSize"
|
||||
)
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1f else 0.5f,
|
||||
label = "dotAlpha"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomableImage(
|
||||
imageUrl: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var offsetY by remember { mutableStateOf(0f) }
|
||||
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = scale,
|
||||
label = "scale"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(0.5f, 4f)
|
||||
if (scale > 1f) {
|
||||
offsetX += pan.x
|
||||
offsetY += pan.y
|
||||
} else {
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
scale = 2.5f
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Photo",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = animatedScale,
|
||||
scaleY = animatedScale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet variant of the photo viewer for modal presentation.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PhotoViewerSheet(
|
||||
imageUrls: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = Color.Black,
|
||||
dragHandle = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
PhotoViewerScreen(
|
||||
imageUrls = imageUrls,
|
||||
initialIndex = initialIndex,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.models.SharedResidence
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Dialog shown when a user attempts to join a residence from a .honeydue file.
|
||||
* Shows residence details and asks for confirmation.
|
||||
*/
|
||||
@Composable
|
||||
fun ResidenceImportConfirmDialog(
|
||||
sharedResidence: SharedResidence,
|
||||
isImporting: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!isImporting) onDismiss() },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Home,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_join_residence_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_join_residence_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Residence details
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = sharedResidence.residenceName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
sharedResidence.sharedBy?.let { sharedBy ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_shared_by, sharedBy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
sharedResidence.expiresAt?.let { expiresAt ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_expires, expiresAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = !isImporting,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isImporting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.properties_joining))
|
||||
} else {
|
||||
Text(stringResource(Res.string.properties_join_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isImporting
|
||||
) {
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown after a residence join attempt succeeds.
|
||||
*/
|
||||
@Composable
|
||||
fun ResidenceImportSuccessDialog(
|
||||
residenceName: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_join_success),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_join_success_message, residenceName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog shown after a residence join attempt fails.
|
||||
*/
|
||||
@Composable
|
||||
fun ResidenceImportErrorDialog(
|
||||
errorMessage: String,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.properties_join_failed),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
if (onRetry != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRetry()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(Res.string.common_try_again))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (onRetry != null) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
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.ChevronRight
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
|
||||
/**
|
||||
* Dropdown showing filtered task suggestions based on user input.
|
||||
* Uses TaskTemplate from backend API.
|
||||
*/
|
||||
@Composable
|
||||
fun TaskSuggestionDropdown(
|
||||
suggestions: List<TaskTemplate>,
|
||||
onSelect: (TaskTemplate) -> Unit,
|
||||
maxSuggestions: Int = 5,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = suggestions.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
modifier = modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 250.dp)
|
||||
) {
|
||||
items(
|
||||
items = suggestions.take(maxSuggestions),
|
||||
key = { it.id }
|
||||
) { template ->
|
||||
TaskSuggestionItem(
|
||||
template = template,
|
||||
onClick = { onSelect(template) }
|
||||
)
|
||||
if (template != suggestions.take(maxSuggestions).last()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 52.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskSuggestionItem(
|
||||
template: TaskTemplate,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Category-colored icon placeholder
|
||||
Surface(
|
||||
modifier = Modifier.size(28.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = getCategoryColor(template.categoryName.lowercase())
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = template.title.first().toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Task info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = template.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = template.categoryName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = template.frequencyDisplay,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chevron
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun getCategoryColor(category: String): androidx.compose.ui.graphics.Color {
|
||||
return when (category.lowercase()) {
|
||||
"plumbing" -> MaterialTheme.colorScheme.secondary
|
||||
"safety", "electrical" -> MaterialTheme.colorScheme.error
|
||||
"hvac" -> MaterialTheme.colorScheme.primary
|
||||
"appliances" -> MaterialTheme.colorScheme.tertiary
|
||||
"exterior", "lawn & garden" -> androidx.compose.ui.graphics.Color(0xFF34C759)
|
||||
"interior" -> androidx.compose.ui.graphics.Color(0xFFAF52DE)
|
||||
"general", "seasonal" -> androidx.compose.ui.graphics.Color(0xFFFF9500)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.models.TaskTemplateCategoryGroup
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Bottom sheet for browsing all task templates from backend.
|
||||
* Uses DataManager to access cached templates.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskTemplatesBrowserSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (TaskTemplate) -> Unit
|
||||
) {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
var expandedCategories by remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
// Get templates from DataManager
|
||||
val groupedTemplates by DataManager.taskTemplatesGrouped.collectAsState()
|
||||
val allTemplates by DataManager.taskTemplates.collectAsState()
|
||||
|
||||
val filteredTemplates = remember(searchText, allTemplates) {
|
||||
if (searchText.isBlank()) emptyList()
|
||||
else DataManager.searchTaskTemplates(searchText)
|
||||
}
|
||||
|
||||
val isSearching = searchText.isNotBlank()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.9f)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.templates_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.templates_done))
|
||||
}
|
||||
}
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = searchText,
|
||||
onValueChange = { searchText = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text(stringResource(Res.string.templates_search_placeholder)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchText.isNotEmpty()) {
|
||||
IconButton(onClick = { searchText = "" }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = stringResource(Res.string.templates_clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Content
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 32.dp)
|
||||
) {
|
||||
if (isSearching) {
|
||||
// Search results
|
||||
if (filteredTemplates.isEmpty()) {
|
||||
item {
|
||||
EmptySearchState()
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
val resultsText = if (filteredTemplates.size == 1) {
|
||||
stringResource(Res.string.templates_result)
|
||||
} else {
|
||||
stringResource(Res.string.templates_results)
|
||||
}
|
||||
Text(
|
||||
text = "${filteredTemplates.size} $resultsText",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
items(filteredTemplates, key = { it.id }) { template ->
|
||||
TaskTemplateItem(
|
||||
template = template,
|
||||
onClick = {
|
||||
onSelect(template)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Browse by category
|
||||
val categories = groupedTemplates?.categories ?: emptyList()
|
||||
|
||||
if (categories.isEmpty()) {
|
||||
item {
|
||||
EmptyTemplatesState()
|
||||
}
|
||||
} else {
|
||||
categories.forEach { categoryGroup ->
|
||||
val categoryKey = categoryGroup.categoryName
|
||||
val isExpanded = expandedCategories.contains(categoryKey)
|
||||
|
||||
item(key = "category_$categoryKey") {
|
||||
CategoryHeader(
|
||||
categoryGroup = categoryGroup,
|
||||
isExpanded = isExpanded,
|
||||
onClick = {
|
||||
expandedCategories = if (isExpanded) {
|
||||
expandedCategories - categoryKey
|
||||
} else {
|
||||
expandedCategories + categoryKey
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
items(categoryGroup.templates, key = { it.id }) { template ->
|
||||
TaskTemplateItem(
|
||||
template = template,
|
||||
onClick = {
|
||||
onSelect(template)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryHeader(
|
||||
categoryGroup: TaskTemplateCategoryGroup,
|
||||
isExpanded: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Category icon
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = getCategoryColor(categoryGroup.categoryName.lowercase())
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = getCategoryIcon(categoryGroup.categoryName.lowercase()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Category name
|
||||
Text(
|
||||
text = categoryGroup.categoryName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Count badge
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Text(
|
||||
text = categoryGroup.count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Expand/collapse indicator
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskTemplateItem(
|
||||
template: TaskTemplate,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Icon placeholder
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = getCategoryColor(template.categoryName.lowercase()).copy(alpha = 0.2f)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = template.title.first().toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = getCategoryColor(template.categoryName.lowercase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Task info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = template.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = template.frequencyDisplay,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Add indicator
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddCircleOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySearchState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.templates_no_results_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.templates_no_results_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyTemplatesState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Checklist,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.templates_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.templates_empty_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getCategoryIcon(category: String): androidx.compose.ui.graphics.vector.ImageVector {
|
||||
return when (category.lowercase()) {
|
||||
"plumbing" -> Icons.Default.Water
|
||||
"safety" -> Icons.Default.Shield
|
||||
"electrical" -> Icons.Default.ElectricBolt
|
||||
"hvac" -> Icons.Default.Thermostat
|
||||
"appliances" -> Icons.Default.Kitchen
|
||||
"exterior" -> Icons.Default.Home
|
||||
"lawn & garden" -> Icons.Default.Park
|
||||
"interior" -> Icons.Default.Weekend
|
||||
"general", "seasonal" -> Icons.Default.CalendarMonth
|
||||
else -> Icons.Default.Checklist
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.tt.honeyDue.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 = "honeyDue",
|
||||
subtitle = "Manage your properties with ease",
|
||||
modifier = Modifier.padding(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tt.honeyDue.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Google Sign In button - only shows on Android platform.
|
||||
* On other platforms, this composable shows nothing.
|
||||
*/
|
||||
@Composable
|
||||
expect fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.ui.theme.AppRadius
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
import com.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.ui.theme.AppRadius
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
import com.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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,63 @@
|
||||
package com.tt.honeyDue.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.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.Home
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun StatItem(
|
||||
icon: ImageVector,
|
||||
value: String,
|
||||
label: String,
|
||||
valueColor: Color? = null
|
||||
) {
|
||||
val effectiveValueColor = valueColor ?: MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = valueColor ?: MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = effectiveValueColor
|
||||
)
|
||||
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,210 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.ui.theme.*
|
||||
import com.tt.honeyDue.platform.HapticFeedbackType
|
||||
import com.tt.honeyDue.platform.rememberHapticFeedback
|
||||
|
||||
/**
|
||||
* 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
|
||||
) {
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
|
||||
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 = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Selection)
|
||||
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.tt.honeyDue.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.tt.honeyDue.models.Document
|
||||
import com.tt.honeyDue.models.DocumentCategory
|
||||
import com.tt.honeyDue.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.tt.honeyDue.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,98 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.models.Document
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.ui.subscription.UpgradeFeatureScreen
|
||||
import com.tt.honeyDue.utils.SubscriptionHelper
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentsTabContent(
|
||||
state: ApiResult<List<Document>>,
|
||||
filteredDocuments: List<Document> = emptyList(),
|
||||
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 -> {
|
||||
// Use filteredDocuments if provided, otherwise fall back to state.data
|
||||
val documents = if (filteredDocuments.isNotEmpty() || state.data.isEmpty()) filteredDocuments else 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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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.tt.honeyDue.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,380 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.models.TaskCompletionResponse
|
||||
import com.tt.honeyDue.models.TaskCompletion
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.util.DateUtils
|
||||
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 = DateUtils.formatDateMedium(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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.models.TaskCompletionImage
|
||||
import com.tt.honeyDue.network.ApiClient
|
||||
import com.tt.honeyDue.ui.components.AuthenticatedImage
|
||||
|
||||
@Composable
|
||||
fun PhotoViewerDialog(
|
||||
images: List<TaskCompletionImage>,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) }
|
||||
|
||||
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
|
||||
) {
|
||||
AuthenticatedImage(
|
||||
mediaUrl = selectedImage!!.mediaUrl,
|
||||
contentDescription = selectedImage!!.caption ?: "Task completion photo",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
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 {
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
contentDescription = image.caption ?: "Task completion photo",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
image.caption?.let { caption ->
|
||||
Text(
|
||||
text = caption,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.tt.honeyDue.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 com.tt.honeyDue.util.DateUtils
|
||||
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: ${DateUtils.formatDate(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,260 @@
|
||||
package com.tt.honeyDue.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 honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.viewmodel.TaskViewModel
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
// MARK: - Edit Task Button
|
||||
@Composable
|
||||
fun EditTaskButton(
|
||||
taskId: Int,
|
||||
onCompletion: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val editText = stringResource(Res.string.common_edit)
|
||||
|
||||
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 = editText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(editText, 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() }
|
||||
) {
|
||||
val cancelText = stringResource(Res.string.tasks_cancel)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_cancel)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.cancelTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
contentDescription = cancelText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(cancelText, 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() }
|
||||
) {
|
||||
val restoreText = stringResource(Res.string.tasks_uncancel)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_restore)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.uncancelTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Undo,
|
||||
contentDescription = restoreText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(restoreText, 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() }
|
||||
) {
|
||||
val inProgressText = stringResource(Res.string.tasks_in_progress_label)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_mark_in_progress)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.markInProgress(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayCircle,
|
||||
contentDescription = inProgressText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(inProgressText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Complete Task Button
|
||||
@Composable
|
||||
fun CompleteTaskButton(
|
||||
taskId: Int,
|
||||
onCompletion: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val completeText = stringResource(Res.string.tasks_mark_complete)
|
||||
|
||||
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 = completeText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(completeText, 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() }
|
||||
) {
|
||||
val archiveText = stringResource(Res.string.tasks_archive)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_archive)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.archiveTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Archive,
|
||||
contentDescription = archiveText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(archiveText, 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() }
|
||||
) {
|
||||
val unarchiveText = stringResource(Res.string.tasks_unarchive)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_unarchive)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.unarchiveTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Unarchive,
|
||||
contentDescription = unarchiveText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(unarchiveText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package com.tt.honeyDue.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 honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.models.TaskDetail
|
||||
import com.tt.honeyDue.models.TaskCategory
|
||||
import com.tt.honeyDue.models.TaskPriority
|
||||
import com.tt.honeyDue.models.TaskFrequency
|
||||
import com.tt.honeyDue.models.TaskCompletion
|
||||
import com.tt.honeyDue.util.DateUtils
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// In Progress badge
|
||||
if (task.inProgress) {
|
||||
val statusColor = MaterialTheme.colorScheme.tertiary
|
||||
Surface(
|
||||
color = statusColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.tasks_card_in_progress),
|
||||
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 = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: stringResource(Res.string.tasks_card_not_available),
|
||||
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 = stringResource(Res.string.tasks_card_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(stringResource(Res.string.tasks_card_mark_in_progress)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
it()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
"complete" -> {
|
||||
onCompleteClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.tasks_card_complete_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
it()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
"edit" -> {
|
||||
onEditClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.tasks_card_edit_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Edit, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
it()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
"cancel" -> {
|
||||
onCancelClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.tasks_card_cancel_task)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Cancel,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
it()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
"uncancel" -> {
|
||||
onUncancelClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.tasks_card_restore_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Undo, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
it()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
"archive" -> {
|
||||
onArchiveClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.tasks_card_archive_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Archive, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
it()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
"unarchive" -> {
|
||||
onUnarchiveClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.tasks_card_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 = stringResource(Res.string.tasks_card_completed_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 = stringResource(Res.string.tasks_card_completed_by, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
completion.actualCost?.let {
|
||||
Text(
|
||||
text = stringResource(Res.string.tasks_card_cost, it.toString()),
|
||||
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 = stringResource(Res.string.tasks_card_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
|
||||
),
|
||||
inProgress = false,
|
||||
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,478 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.models.TaskColumn
|
||||
import com.tt.honeyDue.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,
|
||||
scrollToColumnIndex: Int? = null,
|
||||
onScrollComplete: () -> Unit = {}
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { columns.size })
|
||||
|
||||
// Handle scrolling to a specific column when requested (e.g., from push notification)
|
||||
LaunchedEffect(scrollToColumnIndex) {
|
||||
if (scrollToColumnIndex != null && scrollToColumnIndex in columns.indices) {
|
||||
pagerState.animateScrollToPage(scrollToColumnIndex)
|
||||
onScrollComplete()
|
||||
}
|
||||
}
|
||||
|
||||
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.tt.honeyDue.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.tt.honeyDue.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||
import com.tt.honeyDue.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.tt.honeyDue.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.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,302 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.ui.components.AddNewTaskWithResidenceDialog
|
||||
import com.tt.honeyDue.ui.components.ApiResultHandler
|
||||
import com.tt.honeyDue.ui.components.CompleteTaskDialog
|
||||
import com.tt.honeyDue.ui.components.HandleErrors
|
||||
import com.tt.honeyDue.ui.components.task.TaskCard
|
||||
import com.tt.honeyDue.ui.components.task.DynamicTaskKanbanView
|
||||
import com.tt.honeyDue.viewmodel.ResidenceViewModel
|
||||
import com.tt.honeyDue.viewmodel.TaskCompletionViewModel
|
||||
import com.tt.honeyDue.viewmodel.TaskViewModel
|
||||
import com.tt.honeyDue.models.TaskDetail
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
|
||||
@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,
|
||||
navigateToTaskId: Int? = null,
|
||||
onClearNavigateToTask: () -> Unit = {},
|
||||
onNavigateToCompleteTask: ((TaskDetail, String) -> Unit)? = null
|
||||
) {
|
||||
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) }
|
||||
|
||||
// Track which column to scroll to (from push notification navigation)
|
||||
var scrollToColumnIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadTasks()
|
||||
residenceViewModel.loadMyResidences()
|
||||
}
|
||||
|
||||
// When tasks load and we have a pending navigation, find the column containing the task
|
||||
LaunchedEffect(navigateToTaskId, tasksState) {
|
||||
if (navigateToTaskId != null && tasksState is ApiResult.Success) {
|
||||
val taskData = (tasksState as ApiResult.Success).data
|
||||
// Find which column contains the task
|
||||
taskData.columns.forEachIndexed { index, column ->
|
||||
if (column.tasks.any { it.id == navigateToTaskId }) {
|
||||
println("📬 Found task $navigateToTaskId in column $index '${column.name}'")
|
||||
scrollToColumnIndex = index
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
// Task not found in any column
|
||||
println("📬 Task $navigateToTaskId not found in any column")
|
||||
onClearNavigateToTask()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
WarmGradientBackground {
|
||||
Scaffold(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
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 = androidx.compose.ui.graphics.Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
) { 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(OrganicSpacing.cozy),
|
||||
modifier = Modifier.padding(OrganicSpacing.comfortable)
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Assignment,
|
||||
size = 80.dp,
|
||||
iconScale = 0.6f,
|
||||
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
iconColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"No tasks yet",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.textPrimary
|
||||
)
|
||||
Text(
|
||||
"Create your first task to get started",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.textSecondary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
|
||||
OrganicPrimaryButton(
|
||||
text = "Add Task",
|
||||
onClick = { showNewTaskDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(0.7f),
|
||||
enabled = myResidencesState is ApiResult.Success &&
|
||||
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
|
||||
)
|
||||
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 ->
|
||||
if (onNavigateToCompleteTask != null) {
|
||||
// Use full-screen navigation
|
||||
val residenceName = (myResidencesState as? ApiResult.Success)
|
||||
?.data?.residences?.find { it.id == task.residenceId }?.name ?: ""
|
||||
onNavigateToCompleteTask(task, residenceName)
|
||||
} else {
|
||||
// Fall back to dialog
|
||||
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,
|
||||
scrollToColumnIndex = scrollToColumnIndex,
|
||||
onScrollComplete = {
|
||||
scrollToColumnIndex = null
|
||||
onClearNavigateToTask()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.tt.honeyDue.util.ErrorMessageParser.parse((createTaskState as ApiResult.Error).message)
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package com.tt.honeyDue.ui.screens
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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 honeydue.composeapp.generated.resources.*
|
||||
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||
import com.tt.honeyDue.models.ContractorSummary
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.platform.*
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private const val MAX_IMAGES = 5
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CompleteTaskScreen(
|
||||
taskId: Int,
|
||||
taskTitle: String,
|
||||
residenceName: String = "",
|
||||
onNavigateBack: () -> 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 selectedContractor by remember { mutableStateOf<ContractorSummary?>(null) }
|
||||
var showContractorPicker by remember { mutableStateOf(false) }
|
||||
var isSubmitting by remember { mutableStateOf(false) }
|
||||
|
||||
val contractorsState by contractorViewModel.contractorsState.collectAsState()
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
contractorViewModel.loadContractors()
|
||||
}
|
||||
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
val newTotal = (selectedImages + images).take(MAX_IMAGES)
|
||||
selectedImages = newTotal
|
||||
}
|
||||
|
||||
val cameraPicker = rememberCameraPicker { image ->
|
||||
if (selectedImages.size < MAX_IMAGES) {
|
||||
selectedImages = selectedImages + image
|
||||
}
|
||||
}
|
||||
|
||||
WarmGradientBackground {
|
||||
Scaffold(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(Res.string.completions_complete_task_title, taskTitle),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Task Info Section
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg)
|
||||
) {
|
||||
Text(
|
||||
text = taskTitle,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (residenceName.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Home,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = residenceName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contractor Section
|
||||
SectionHeader(
|
||||
title = stringResource(Res.string.completions_select_contractor),
|
||||
subtitle = stringResource(Res.string.completions_contractor_helper)
|
||||
)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.lg)
|
||||
.clickable { showContractorPicker = true }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Build,
|
||||
size = 24.dp
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = selectedContractor?.name
|
||||
?: stringResource(Res.string.completions_none_manual),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
selectedContractor?.company?.let { company ->
|
||||
Text(
|
||||
text = company,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
// Completion Details Section
|
||||
SectionHeader(
|
||||
title = stringResource(Res.string.completions_details_section),
|
||||
subtitle = stringResource(Res.string.completions_optional_info)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
// Completed By Name
|
||||
OutlinedTextField(
|
||||
value = completedByName,
|
||||
onValueChange = { completedByName = it },
|
||||
label = { Text(stringResource(Res.string.completions_completed_by_name)) },
|
||||
placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = selectedContractor == null,
|
||||
shape = OrganicShapes.medium
|
||||
)
|
||||
|
||||
// Actual Cost
|
||||
OutlinedTextField(
|
||||
value = actualCost,
|
||||
onValueChange = { actualCost = it },
|
||||
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
|
||||
leadingIcon = { Icon(Icons.Default.AttachMoney, null) },
|
||||
prefix = { Text("$") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
shape = OrganicShapes.medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
// Notes Section
|
||||
SectionHeader(
|
||||
title = stringResource(Res.string.completions_notes_optional),
|
||||
subtitle = stringResource(Res.string.completions_notes_helper)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
placeholder = { Text(stringResource(Res.string.completions_notes_placeholder)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.lg)
|
||||
.height(120.dp),
|
||||
shape = OrganicShapes.medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
// Rating Section
|
||||
SectionHeader(
|
||||
title = stringResource(Res.string.completions_quality_rating),
|
||||
subtitle = stringResource(Res.string.completions_rate_quality)
|
||||
)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.lg)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "$rating / 5",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.md))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
(1..5).forEach { star ->
|
||||
val isSelected = star <= rating
|
||||
val starColor by animateColorAsState(
|
||||
targetValue = if (isSelected) Color(0xFFFFD700)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "starColor"
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Selection)
|
||||
rating = star
|
||||
},
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = "$star stars",
|
||||
tint = starColor,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
|
||||
// Photos Section
|
||||
SectionHeader(
|
||||
title = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
|
||||
subtitle = stringResource(Res.string.completions_add_photos_helper)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
cameraPicker()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < MAX_IMAGES
|
||||
) {
|
||||
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(stringResource(Res.string.completions_camera))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
imagePicker()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < MAX_IMAGES
|
||||
) {
|
||||
Icon(Icons.Default.PhotoLibrary, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(stringResource(Res.string.completions_library))
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
selectedImages.forEachIndexed { index, imageData ->
|
||||
ImageThumbnailCard(
|
||||
imageData = imageData,
|
||||
onRemove = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
selectedImages = selectedImages.toMutableList().also {
|
||||
it.removeAt(index)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
|
||||
// Complete Button
|
||||
OrganicPrimaryButton(
|
||||
text = stringResource(Res.string.completions_complete_button),
|
||||
onClick = {
|
||||
isSubmitting = true
|
||||
val notesWithContractor = buildString {
|
||||
selectedContractor?.let {
|
||||
append("Contractor: ${it.name}")
|
||||
it.company?.let { company -> append(" ($company)") }
|
||||
append("\n")
|
||||
}
|
||||
if (completedByName.isNotBlank()) {
|
||||
append("Completed by: $completedByName\n")
|
||||
}
|
||||
if (notes.isNotBlank()) {
|
||||
append(notes)
|
||||
}
|
||||
}.ifBlank { null }
|
||||
|
||||
onComplete(
|
||||
TaskCompletionCreateRequest(
|
||||
taskId = taskId,
|
||||
completedAt = null,
|
||||
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
|
||||
notes = notesWithContractor,
|
||||
rating = rating,
|
||||
imageUrls = null
|
||||
),
|
||||
selectedImages
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.lg),
|
||||
enabled = !isSubmitting,
|
||||
isLoading = isSubmitting,
|
||||
icon = Icons.Default.CheckCircle
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contractor Picker Bottom Sheet
|
||||
if (showContractorPicker) {
|
||||
ContractorPickerSheet(
|
||||
contractors = when (val state = contractorsState) {
|
||||
is ApiResult.Success -> state.data
|
||||
else -> emptyList()
|
||||
},
|
||||
isLoading = contractorsState is ApiResult.Loading,
|
||||
selectedContractor = selectedContractor,
|
||||
onSelect = { contractor ->
|
||||
selectedContractor = contractor
|
||||
showContractorPicker = false
|
||||
},
|
||||
onDismiss = { showContractorPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
title: String,
|
||||
subtitle: String? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageThumbnailCard(
|
||||
imageData: ImageData,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val imageBitmap = rememberImageBitmap(imageData)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.clip(OrganicShapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = imageData.fileName,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(OrganicSpacing.xs)
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.error)
|
||||
.clickable(onClick = onRemove),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.onError,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ContractorPickerSheet(
|
||||
contractors: List<ContractorSummary>,
|
||||
isLoading: Boolean,
|
||||
selectedContractor: ContractorSummary?,
|
||||
onSelect: (ContractorSummary?) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = OrganicSpacing.xl)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_select_contractor),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
|
||||
)
|
||||
|
||||
OrganicDivider()
|
||||
|
||||
// None option
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(Res.string.completions_none_manual)) },
|
||||
supportingContent = { Text(stringResource(Res.string.completions_enter_manually)) },
|
||||
trailingContent = {
|
||||
if (selectedContractor == null) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onSelect(null) }
|
||||
)
|
||||
|
||||
OrganicDivider()
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.xl),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
contractors.forEach { contractor ->
|
||||
ListItem(
|
||||
headlineContent = { Text(contractor.name) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
contractor.company?.let { Text(it) }
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (selectedContractor?.id == contractor.id) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onSelect(contractor) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
package com.tt.honeyDue.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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
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.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.ui.components.AddContractorDialog
|
||||
import com.tt.honeyDue.ui.components.ApiResultHandler
|
||||
import com.tt.honeyDue.ui.components.HandleErrors
|
||||
import com.tt.honeyDue.util.DateUtils
|
||||
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.platform.rememberShareContractor
|
||||
import com.tt.honeyDue.utils.SubscriptionHelper
|
||||
import com.tt.honeyDue.ui.subscription.UpgradePromptDialog
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@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) }
|
||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val shareContractor = rememberShareContractor()
|
||||
|
||||
LaunchedEffect(contractorId) {
|
||||
viewModel.loadContractorDetail(contractorId)
|
||||
}
|
||||
|
||||
// Handle errors for delete contractor
|
||||
deleteState.HandleErrors(
|
||||
onRetry = { viewModel.deleteContractor(contractorId) },
|
||||
errorTitle = stringResource(Res.string.contractors_failed_to_delete)
|
||||
)
|
||||
|
||||
// Handle errors for toggle favorite
|
||||
toggleFavoriteState.HandleErrors(
|
||||
onRetry = { viewModel.toggleFavorite(contractorId) },
|
||||
errorTitle = stringResource(Res.string.contractors_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(stringResource(Res.string.contractors_details), fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, stringResource(Res.string.common_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
when (val state = contractorState) {
|
||||
is ApiResult.Success -> {
|
||||
IconButton(onClick = {
|
||||
val shareCheck = SubscriptionHelper.canShareContractor()
|
||||
if (shareCheck.allowed) {
|
||||
shareContractor(state.data)
|
||||
} else {
|
||||
upgradeTriggerKey = shareCheck.triggerKey
|
||||
showUpgradePrompt = true
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Share, stringResource(Res.string.common_share))
|
||||
}
|
||||
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
|
||||
Icon(
|
||||
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
stringResource(Res.string.contractors_toggle_favorite),
|
||||
tint = if (state.data.isFavorite) Color(0xFFF59E0B) else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showEditDialog = true }) {
|
||||
Icon(Icons.Default.Edit, stringResource(Res.string.common_edit))
|
||||
}
|
||||
IconButton(onClick = { showDeleteConfirmation = true }) {
|
||||
Icon(Icons.Default.Delete, stringResource(Res.string.common_delete), tint = Color(0xFFEF4444))
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
WarmGradientBackground {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val residences = DataManager.residences.value
|
||||
|
||||
ApiResultHandler(
|
||||
state = contractorState,
|
||||
onRetry = { viewModel.loadContractorDetail(contractorId) },
|
||||
errorTitle = stringResource(Res.string.contractors_failed_to_load),
|
||||
loadingContent = {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
) { contractor ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(OrganicSpacing.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
|
||||
) {
|
||||
// Header Card
|
||||
item {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.large),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Avatar
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Person,
|
||||
size = 80.dp,
|
||||
iconSize = 48.dp,
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
iconTint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.medium))
|
||||
|
||||
Text(
|
||||
text = contractor.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (contractor.company != null) {
|
||||
Text(
|
||||
text = contractor.company,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (contractor.specialties.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.medium))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.small, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
|
||||
) {
|
||||
contractor.specialties.forEach { specialty ->
|
||||
Surface(
|
||||
shape = OrganicShapes.large,
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.extraSmall),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
|
||||
Text(
|
||||
text = specialty.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contractor.rating != null && contractor.rating > 0) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.medium))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(5) { index ->
|
||||
Icon(
|
||||
if (index < contractor.rating.toInt()) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color(0xFFF59E0B)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.small))
|
||||
Text(
|
||||
text = ((contractor.rating * 10).toInt() / 10.0).toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (contractor.taskCount > 0) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.extraSmall))
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_completed_tasks, contractor.taskCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Actions
|
||||
if (contractor.phone != null || contractor.email != null || contractor.website != null) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
|
||||
) {
|
||||
contractor.phone?.let { phone ->
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Phone,
|
||||
label = stringResource(Res.string.contractors_call),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
try {
|
||||
uriHandler.openUri("tel:${phone.replace(" ", "")}")
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
contractor.email?.let { email ->
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Email,
|
||||
label = stringResource(Res.string.contractors_send_email),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
try {
|
||||
uriHandler.openUri("mailto:$email")
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
contractor.website?.let { website ->
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Language,
|
||||
label = stringResource(Res.string.contractors_website),
|
||||
color = Color(0xFFF59E0B),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
try {
|
||||
val url = if (website.startsWith("http")) website else "https://$website"
|
||||
uriHandler.openUri(url)
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (contractor.streetAddress != null || contractor.city != null) {
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Map,
|
||||
label = stringResource(Res.string.contractors_directions),
|
||||
color = Color(0xFFEF4444),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
try {
|
||||
val address = listOfNotNull(
|
||||
contractor.streetAddress,
|
||||
contractor.city,
|
||||
contractor.stateProvince,
|
||||
contractor.postalCode
|
||||
).joinToString(", ")
|
||||
uriHandler.openUri("geo:0,0?q=$address")
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contact Information
|
||||
item {
|
||||
DetailSection(title = stringResource(Res.string.contractors_contact_info)) {
|
||||
contractor.phone?.let { phone ->
|
||||
ClickableDetailRow(
|
||||
icon = Icons.Default.Phone,
|
||||
label = stringResource(Res.string.contractors_phone_label),
|
||||
value = phone,
|
||||
iconTint = MaterialTheme.colorScheme.primary,
|
||||
onClick = {
|
||||
try {
|
||||
uriHandler.openUri("tel:${phone.replace(" ", "")}")
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
contractor.email?.let { email ->
|
||||
ClickableDetailRow(
|
||||
icon = Icons.Default.Email,
|
||||
label = stringResource(Res.string.contractors_email_label),
|
||||
value = email,
|
||||
iconTint = MaterialTheme.colorScheme.secondary,
|
||||
onClick = {
|
||||
try {
|
||||
uriHandler.openUri("mailto:$email")
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
contractor.website?.let { website ->
|
||||
ClickableDetailRow(
|
||||
icon = Icons.Default.Language,
|
||||
label = stringResource(Res.string.contractors_website),
|
||||
value = website,
|
||||
iconTint = Color(0xFFF59E0B),
|
||||
onClick = {
|
||||
try {
|
||||
val url = if (website.startsWith("http")) website else "https://$website"
|
||||
uriHandler.openUri(url)
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (contractor.phone == null && contractor.email == null && contractor.website == null) {
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_no_contact_info),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(OrganicSpacing.medium)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
if (contractor.streetAddress != null || contractor.city != null) {
|
||||
item {
|
||||
DetailSection(title = stringResource(Res.string.contractors_address)) {
|
||||
val fullAddress = buildString {
|
||||
contractor.streetAddress?.let { append(it) }
|
||||
if (contractor.city != null || contractor.stateProvince != null || contractor.postalCode != null) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
contractor.city?.let { append(it) }
|
||||
contractor.stateProvince?.let {
|
||||
if (contractor.city != null) append(", ")
|
||||
append(it)
|
||||
}
|
||||
contractor.postalCode?.let {
|
||||
append(" ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullAddress.isNotBlank()) {
|
||||
ClickableDetailRow(
|
||||
icon = Icons.Default.LocationOn,
|
||||
label = stringResource(Res.string.contractors_location),
|
||||
value = fullAddress,
|
||||
iconTint = Color(0xFFEF4444),
|
||||
onClick = {
|
||||
try {
|
||||
val address = listOfNotNull(
|
||||
contractor.streetAddress,
|
||||
contractor.city,
|
||||
contractor.stateProvince,
|
||||
contractor.postalCode
|
||||
).joinToString(", ")
|
||||
uriHandler.openUri("geo:0,0?q=$address")
|
||||
} catch (e: Exception) { /* Handle error */ }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Associated Property
|
||||
contractor.residenceId?.let { resId ->
|
||||
val residenceName = residences.find { r -> r.id == resId }?.name
|
||||
?: "Property #$resId"
|
||||
|
||||
item {
|
||||
DetailSection(title = stringResource(Res.string.contractors_associated_property)) {
|
||||
DetailRow(
|
||||
icon = Icons.Default.Home,
|
||||
label = stringResource(Res.string.contractors_property),
|
||||
value = residenceName,
|
||||
iconTint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (!contractor.notes.isNullOrBlank()) {
|
||||
item {
|
||||
DetailSection(title = stringResource(Res.string.contractors_notes)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.medium),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Notes,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color(0xFFF59E0B)
|
||||
)
|
||||
Text(
|
||||
text = contractor.notes,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics
|
||||
item {
|
||||
DetailSection(title = stringResource(Res.string.contractors_statistics)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.medium),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatCard(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
value = contractor.taskCount.toString(),
|
||||
label = stringResource(Res.string.contractors_tasks_completed),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
if (contractor.rating != null && contractor.rating > 0) {
|
||||
StatCard(
|
||||
icon = Icons.Default.Star,
|
||||
value = ((contractor.rating * 10).toInt() / 10.0).toString(),
|
||||
label = stringResource(Res.string.contractors_average_rating),
|
||||
color = Color(0xFFF59E0B)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
item {
|
||||
DetailSection(title = stringResource(Res.string.contractors_info)) {
|
||||
contractor.createdBy?.let { createdBy ->
|
||||
DetailRow(
|
||||
icon = Icons.Default.PersonAdd,
|
||||
label = stringResource(Res.string.contractors_added_by),
|
||||
value = createdBy.username,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.medium))
|
||||
}
|
||||
|
||||
DetailRow(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
label = stringResource(Res.string.contractors_member_since),
|
||||
value = DateUtils.formatDateMedium(contractor.createdAt),
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(stringResource(Res.string.contractors_delete)) },
|
||||
text = { Text(stringResource(Res.string.contractors_delete_warning)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.deleteContractor(contractorId)
|
||||
showDeleteConfirmation = false
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444))
|
||||
) {
|
||||
Text(stringResource(Res.string.common_delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirmation = false }) {
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = OrganicShapes.large
|
||||
)
|
||||
}
|
||||
|
||||
if (showUpgradePrompt && upgradeTriggerKey != null) {
|
||||
UpgradePromptDialog(
|
||||
triggerKey = upgradeTriggerKey!!,
|
||||
onDismiss = {
|
||||
showUpgradePrompt = false
|
||||
upgradeTriggerKey = null
|
||||
},
|
||||
onUpgrade = {
|
||||
// TODO: Navigate to subscription purchase screen
|
||||
showUpgradePrompt = false
|
||||
upgradeTriggerKey = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(OrganicSpacing.medium).padding(bottom = 0.dp)
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.medium),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.medium))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableDetailRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.medium),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.medium))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.OpenInNew,
|
||||
contentDescription = "Open",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickActionButton(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OrganicCard(
|
||||
modifier = modifier.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = OrganicSpacing.medium),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = icon,
|
||||
size = 44.dp,
|
||||
iconSize = 22.dp,
|
||||
containerColor = color.copy(alpha = 0.1f),
|
||||
iconTint = color
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.small))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatCard(
|
||||
icon: ImageVector,
|
||||
value: String,
|
||||
label: String,
|
||||
color: Color
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = icon,
|
||||
size = 44.dp,
|
||||
iconSize = 22.dp,
|
||||
containerColor = color.copy(alpha = 0.1f),
|
||||
iconTint = color
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.small))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package com.tt.honeyDue.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.material3.pulltorefresh.PullToRefreshBox
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.ui.components.AddContractorDialog
|
||||
import com.tt.honeyDue.ui.components.ApiResultHandler
|
||||
import com.tt.honeyDue.ui.components.HandleErrors
|
||||
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||
import com.tt.honeyDue.models.ContractorSummary
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.repository.LookupsRepository
|
||||
import com.tt.honeyDue.ui.subscription.UpgradeFeatureScreen
|
||||
import com.tt.honeyDue.utils.SubscriptionHelper
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@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) {
|
||||
PostHogAnalytics.screen(AnalyticsEvents.CONTRACTOR_SCREEN_SHOWN)
|
||||
viewModel.loadContractors()
|
||||
}
|
||||
|
||||
// Handle refresh state
|
||||
LaunchedEffect(contractorsState) {
|
||||
if (contractorsState !is ApiResult.Loading) {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side filtering since backend doesn't support search/filter params
|
||||
val filteredContractors = remember(contractorsState, searchQuery, selectedFilter, showFavoritesOnly) {
|
||||
val contractors = (contractorsState as? ApiResult.Success)?.data ?: emptyList()
|
||||
contractors.filter { contractor ->
|
||||
val matchesSearch = searchQuery.isBlank() ||
|
||||
contractor.name.contains(searchQuery, ignoreCase = true) ||
|
||||
(contractor.company?.contains(searchQuery, ignoreCase = true) == true)
|
||||
val matchesSpecialty = selectedFilter == null ||
|
||||
contractor.specialties.any { it.name == selectedFilter }
|
||||
val matchesFavorite = !showFavoritesOnly || contractor.isFavorite
|
||||
matchesSearch && matchesSpecialty && matchesFavorite
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors for delete contractor
|
||||
deleteState.HandleErrors(
|
||||
onRetry = { /* Handled in UI */ },
|
||||
errorTitle = stringResource(Res.string.contractors_failed_to_delete)
|
||||
)
|
||||
|
||||
// Handle errors for toggle favorite
|
||||
toggleFavoriteState.HandleErrors(
|
||||
onRetry = { /* Handled in UI */ },
|
||||
errorTitle = stringResource(Res.string.contractors_failed_to_update_favorite)
|
||||
)
|
||||
|
||||
LaunchedEffect(deleteState) {
|
||||
if (deleteState is ApiResult.Success) {
|
||||
viewModel.loadContractors()
|
||||
viewModel.resetDeleteState()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(toggleFavoriteState) {
|
||||
if (toggleFavoriteState is ApiResult.Success) {
|
||||
viewModel.loadContractors()
|
||||
viewModel.resetToggleFavoriteState()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.contractors_title), fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
// Favorites filter toggle
|
||||
IconButton(onClick = { showFavoritesOnly = !showFavoritesOnly }) {
|
||||
Icon(
|
||||
if (showFavoritesOnly) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
stringResource(Res.string.contractors_filter_favorites),
|
||||
tint = if (showFavoritesOnly) MaterialTheme.colorScheme.tertiary else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
|
||||
// Specialty filter menu
|
||||
Box {
|
||||
IconButton(onClick = { showFiltersMenu = true }) {
|
||||
Icon(
|
||||
Icons.Default.FilterList,
|
||||
stringResource(Res.string.contractors_filter_specialty),
|
||||
tint = if (selectedFilter != null) MaterialTheme.colorScheme.primary else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showFiltersMenu,
|
||||
onDismissRequest = { showFiltersMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.contractors_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 {
|
||||
PostHogAnalytics.capture(
|
||||
AnalyticsEvents.CONTRACTOR_PAYWALL_SHOWN,
|
||||
mapOf("current_count" to currentCount)
|
||||
)
|
||||
showUpgradeDialog = true
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
) {
|
||||
Icon(Icons.Default.Add, stringResource(Res.string.contractors_add_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { 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 {
|
||||
WarmGradientBackground {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.small),
|
||||
placeholder = { Text(stringResource(Res.string.contractors_search)) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, stringResource(Res.string.common_search)) },
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
Icon(Icons.Default.Close, stringResource(Res.string.contractors_clear_search))
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = OrganicShapes.medium,
|
||||
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 = OrganicSpacing.medium, vertical = OrganicSpacing.extraSmall),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
|
||||
) {
|
||||
if (showFavoritesOnly) {
|
||||
FilterChip(
|
||||
selected = true,
|
||||
onClick = { showFavoritesOnly = false },
|
||||
label = { Text(stringResource(Res.string.contractors_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()
|
||||
},
|
||||
errorTitle = stringResource(Res.string.contractors_failed_to_load),
|
||||
loadingContent = {
|
||||
if (!isRefreshing) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
) { _ ->
|
||||
// Use filteredContractors for client-side filtering
|
||||
if (filteredContractors.isEmpty()) {
|
||||
// Empty state with organic styling
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.PersonAdd,
|
||||
size = 80.dp,
|
||||
iconSize = 40.dp,
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
iconTint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.small))
|
||||
Text(
|
||||
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
|
||||
stringResource(Res.string.contractors_no_results)
|
||||
else
|
||||
stringResource(Res.string.contractors_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
|
||||
Text(
|
||||
stringResource(Res.string.contractors_empty_subtitle_first),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.loadContractors()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(OrganicSpacing.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
|
||||
) {
|
||||
items(filteredContractors, 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(stringResource(Res.string.contractors_upgrade_required)) },
|
||||
text = {
|
||||
Text(stringResource(Res.string.contractors_upgrade_message))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showUpgradeDialog = false }) {
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContractorCard(
|
||||
contractor: ContractorSummary,
|
||||
onToggleFavorite: (Int) -> Unit,
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick(contractor.id) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.medium),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar/Icon
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Person,
|
||||
size = 56.dp,
|
||||
iconSize = 32.dp,
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
iconTint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.medium))
|
||||
|
||||
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(OrganicSpacing.extraSmall))
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = stringResource(Res.string.contractors_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(OrganicSpacing.small))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (contractor.specialties.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.WorkOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
|
||||
Text(
|
||||
text = contractor.specialties.first().name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (contractor.rating != null && contractor.rating > 0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
|
||||
Text(
|
||||
text = "${(contractor.rating * 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(OrganicSpacing.extraSmall))
|
||||
Text(
|
||||
text = stringResource(Res.string.contractors_tasks_count, contractor.taskCount),
|
||||
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) stringResource(Res.string.contractors_remove_from_favorites) else stringResource(Res.string.contractors_add_to_favorites),
|
||||
tint = if (contractor.isFavorite) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Arrow icon
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = stringResource(Res.string.contractors_view_details),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.ui.components.ApiResultHandler
|
||||
import com.tt.honeyDue.ui.components.HandleErrors
|
||||
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||
import com.tt.honeyDue.models.*
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import androidx.compose.foundation.Image
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.tt.honeyDue.ui.components.documents.ErrorState
|
||||
import com.tt.honeyDue.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
|
||||
import com.tt.honeyDue.ui.components.AuthenticatedImage
|
||||
import com.tt.honeyDue.util.DateUtils
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@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 = stringResource(Res.string.documents_failed_to_delete)
|
||||
)
|
||||
|
||||
// Handle successful deletion
|
||||
LaunchedEffect(deleteState) {
|
||||
if (deleteState is ApiResult.Success) {
|
||||
documentViewModel.resetDeleteState()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.documents_details), fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, stringResource(Res.string.common_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
when (documentState) {
|
||||
is ApiResult.Success -> {
|
||||
IconButton(onClick = { onNavigateToEdit(documentId) }) {
|
||||
Icon(Icons.Default.Edit, stringResource(Res.string.common_edit))
|
||||
}
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(Icons.Default.Delete, stringResource(Res.string.common_delete), tint = Color.Red)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
WarmGradientBackground {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
ApiResultHandler(
|
||||
state = documentState,
|
||||
onRetry = { documentViewModel.loadDocumentDetail(documentId) },
|
||||
errorTitle = stringResource(Res.string.documents_failed_to_load)
|
||||
) { document ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = OrganicSpacing.lg, end = OrganicSpacing.lg, top = OrganicSpacing.lg, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
|
||||
) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
accentColor = statusColor.copy(alpha = 0.1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(Res.string.documents_status),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
when {
|
||||
!document.isActive -> stringResource(Res.string.documents_inactive)
|
||||
daysUntilExpiration < 0 -> stringResource(Res.string.documents_expired)
|
||||
daysUntilExpiration < 30 -> stringResource(Res.string.documents_expiring_soon)
|
||||
else -> stringResource(Res.string.documents_active)
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
if (document.isActive && daysUntilExpiration >= 0) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_days_remaining),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
"$daysUntilExpiration",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Basic Information
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_basic_info),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
DetailRow(stringResource(Res.string.documents_title_label), document.title)
|
||||
DetailRow(stringResource(Res.string.documents_type_label), DocumentType.fromValue(document.documentType).displayName)
|
||||
document.category?.let {
|
||||
DetailRow(stringResource(Res.string.documents_all_categories), DocumentCategory.fromValue(it).displayName)
|
||||
}
|
||||
document.description?.let {
|
||||
DetailRow(stringResource(Res.string.documents_description_label), it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warranty/Item Details (for warranties)
|
||||
if (document.documentType == "warranty" &&
|
||||
(document.itemName != null || document.modelNumber != null ||
|
||||
document.serialNumber != null || document.provider != null)) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_item_details),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.itemName?.let { DetailRow(stringResource(Res.string.documents_item_name), it) }
|
||||
document.modelNumber?.let { DetailRow(stringResource(Res.string.documents_model_number), it) }
|
||||
document.serialNumber?.let { DetailRow(stringResource(Res.string.documents_serial_number), it) }
|
||||
document.provider?.let { DetailRow(stringResource(Res.string.documents_provider), it) }
|
||||
document.providerContact?.let { DetailRow(stringResource(Res.string.documents_provider_contact), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Claim Information (for warranties)
|
||||
if (document.documentType == "warranty" &&
|
||||
(document.claimPhone != null || document.claimEmail != null ||
|
||||
document.claimWebsite != null)) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_claim_info),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.claimPhone?.let { DetailRow(stringResource(Res.string.documents_claim_phone), it) }
|
||||
document.claimEmail?.let { DetailRow(stringResource(Res.string.documents_claim_email), it) }
|
||||
document.claimWebsite?.let { DetailRow(stringResource(Res.string.documents_claim_website), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dates
|
||||
if (document.purchaseDate != null || document.startDate != null ||
|
||||
document.endDate != null) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_important_dates),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.purchaseDate?.let { DetailRow(stringResource(Res.string.documents_purchase_date), DateUtils.formatDateMedium(it)) }
|
||||
document.startDate?.let { DetailRow(stringResource(Res.string.documents_start_date), DateUtils.formatDateMedium(it)) }
|
||||
document.endDate?.let { DetailRow(stringResource(Res.string.documents_end_date), DateUtils.formatDateMedium(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Residence & Contractor
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_associations),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.residenceId?.let { DetailRow(stringResource(Res.string.documents_residence), "Residence #$it") }
|
||||
document.taskId?.let { DetailRow(stringResource(Res.string.documents_contractor), "Task #$it") }
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Information
|
||||
if (document.tags != null || document.notes != null) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_additional_info),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.tags?.let { DetailRow(stringResource(Res.string.documents_tags), it) }
|
||||
document.notes?.let { DetailRow(stringResource(Res.string.documents_notes), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
if (document.images.isNotEmpty()) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_images, document.images.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
// Image grid
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
|
||||
) {
|
||||
document.images.take(4).forEachIndexed { index, image ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clickable {
|
||||
selectedPhotoIndex = index
|
||||
showPhotoViewer = true
|
||||
}
|
||||
) {
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
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) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_attached_file),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.mimeType?.let { DetailRow(stringResource(Res.string.documents_file_type), it) }
|
||||
document.fileSize?.let {
|
||||
DetailRow(stringResource(Res.string.documents_file_size), formatFileSize(it))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { /* TODO: Download file */ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Download, null)
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Text(stringResource(Res.string.documents_download_file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_metadata),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OrganicDivider()
|
||||
|
||||
document.createdBy?.let { user ->
|
||||
val name = listOfNotNull(user.firstName, user.lastName).joinToString(" ").ifEmpty { user.username }
|
||||
DetailRow(stringResource(Res.string.documents_uploaded_by), name)
|
||||
}
|
||||
document.createdAt?.let { DetailRow(stringResource(Res.string.documents_created), DateUtils.formatDateMedium(it)) }
|
||||
document.updatedAt?.let { DetailRow(stringResource(Res.string.documents_updated), DateUtils.formatDateMedium(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text(stringResource(Res.string.documents_delete)) },
|
||||
text = { Text(stringResource(Res.string.documents_delete_warning)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
documentViewModel.deleteDocument(documentId)
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.common_delete), color = Color.Red)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text(stringResource(Res.string.common_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(OrganicSpacing.xs))
|
||||
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(OrganicSpacing.lg),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OrganicDivider()
|
||||
|
||||
// Content
|
||||
if (showFullImage) {
|
||||
// Single image view
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
AuthenticatedImage(
|
||||
mediaUrl = images[selectedIndex].mediaUrl,
|
||||
contentDescription = images[selectedIndex].caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit
|
||||
)
|
||||
|
||||
images[selectedIndex].caption?.let { caption ->
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = caption,
|
||||
modifier = Modifier.padding(OrganicSpacing.lg),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
if (images.size > 1) {
|
||||
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
|
||||
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(OrganicSpacing.sm))
|
||||
Text("Previous")
|
||||
}
|
||||
Button(
|
||||
onClick = { selectedIndex = (selectedIndex + 1) % images.size },
|
||||
enabled = selectedIndex < images.size - 1
|
||||
) {
|
||||
Text("Next")
|
||||
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
|
||||
Icon(Icons.Default.ArrowForward, "Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Grid view
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
) {
|
||||
items(images.size) { index ->
|
||||
val image = images[index]
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
selectedIndex = index
|
||||
showFullImage = true
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
contentDescription = image.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
|
||||
image.caption?.let { caption ->
|
||||
Text(
|
||||
text = caption,
|
||||
modifier = Modifier.padding(OrganicSpacing.sm),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.ui.components.AuthenticatedImage
|
||||
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||
import com.tt.honeyDue.viewmodel.ResidenceViewModel
|
||||
import com.tt.honeyDue.models.*
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.platform.ImageData
|
||||
import com.tt.honeyDue.platform.rememberImagePicker
|
||||
import com.tt.honeyDue.platform.rememberCameraPicker
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@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 existingImages by remember { mutableStateOf<List<DocumentImage>>(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
|
||||
}
|
||||
}
|
||||
|
||||
// Track screen view
|
||||
LaunchedEffect(Unit) {
|
||||
if (!isEditMode) {
|
||||
val type = if (initialDocumentType == "warranty") "warranty" else "document"
|
||||
PostHogAnalytics.screen(
|
||||
AnalyticsEvents.NEW_DOCUMENT_SCREEN_SHOWN,
|
||||
mapOf("type" to type)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
existingImages = document.images
|
||||
|
||||
// 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) {
|
||||
// Track document creation
|
||||
val type = if (selectedDocumentType == "warranty") "warranty" else "document"
|
||||
PostHogAnalytics.capture(
|
||||
AnalyticsEvents.DOCUMENT_CREATED,
|
||||
mapOf("type" to type)
|
||||
)
|
||||
}
|
||||
if (isEditMode) {
|
||||
documentViewModel.resetUpdateState()
|
||||
} else {
|
||||
documentViewModel.resetCreateState()
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
when {
|
||||
isEditMode && isWarranty -> stringResource(Res.string.documents_form_edit_warranty)
|
||||
isEditMode -> stringResource(Res.string.documents_form_edit_document)
|
||||
isWarranty -> stringResource(Res.string.documents_form_add_warranty)
|
||||
else -> stringResource(Res.string.documents_form_add_document)
|
||||
}
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, stringResource(Res.string.common_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
WarmGradientBackground {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = OrganicSpacing.cozy, end = OrganicSpacing.cozy, top = OrganicSpacing.cozy, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
|
||||
) {
|
||||
// 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 ?: stringResource(Res.string.documents_form_select_residence),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(Res.string.documents_form_residence_required)) },
|
||||
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(
|
||||
stringResource(Res.string.documents_form_failed_to_load_residences, com.tt.honeyDue.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(stringResource(Res.string.documents_form_document_type_required)) },
|
||||
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(stringResource(Res.string.documents_form_title_required)) },
|
||||
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(stringResource(Res.string.documents_form_item_name_required)) },
|
||||
isError = itemNameError.isNotEmpty(),
|
||||
supportingText = if (itemNameError.isNotEmpty()) {
|
||||
{ Text(itemNameError) }
|
||||
} else null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = modelNumber,
|
||||
onValueChange = { modelNumber = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_model_number)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = serialNumber,
|
||||
onValueChange = { serialNumber = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_serial_number)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = provider,
|
||||
onValueChange = {
|
||||
provider = it
|
||||
providerError = ""
|
||||
},
|
||||
label = { Text(stringResource(Res.string.documents_form_provider_required)) },
|
||||
isError = providerError.isNotEmpty(),
|
||||
supportingText = if (providerError.isNotEmpty()) {
|
||||
{ Text(providerError) }
|
||||
} else null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = providerContact,
|
||||
onValueChange = { providerContact = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_provider_contact)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimPhone,
|
||||
onValueChange = { claimPhone = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_claim_phone)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimEmail,
|
||||
onValueChange = { claimEmail = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_claim_email)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimWebsite,
|
||||
onValueChange = { claimWebsite = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_claim_website)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = purchaseDate,
|
||||
onValueChange = { purchaseDate = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_purchase_date)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = startDate,
|
||||
onValueChange = { startDate = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_warranty_start)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = endDate,
|
||||
onValueChange = { endDate = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_warranty_end_required)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder_end)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_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 } ?: stringResource(Res.string.documents_form_select_category),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(Res.string.documents_form_category)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = categoryExpanded,
|
||||
onDismissRequest = { categoryExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.documents_form_category_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(stringResource(Res.string.documents_form_tags)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_tags_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Notes
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text(stringResource(Res.string.documents_form_notes)) },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Active toggle (edit mode only)
|
||||
if (isEditMode) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(Res.string.documents_form_active))
|
||||
Switch(
|
||||
checked = isActive,
|
||||
onCheckedChange = { isActive = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing images (edit mode only)
|
||||
if (isEditMode && existingImages.isNotEmpty()) {
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
showBlob = false
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.cozy),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.documents_form_existing_photos, existingImages.size),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
existingImages.forEach { image ->
|
||||
AuthenticatedImage(
|
||||
mediaUrl = image.mediaUrl,
|
||||
contentDescription = image.caption,
|
||||
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
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
showBlob = false
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(OrganicSpacing.cozy),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
if (isEditMode) {
|
||||
stringResource(Res.string.documents_form_new_photos, selectedImages.size, maxImages)
|
||||
} else {
|
||||
stringResource(Res.string.documents_form_photos, selectedImages.size, maxImages)
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
|
||||
) {
|
||||
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(stringResource(Res.string.documents_form_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(stringResource(Res.string.documents_form_gallery))
|
||||
}
|
||||
}
|
||||
|
||||
// Display selected images
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
|
||||
) {
|
||||
selectedImages.forEachIndexed { index, image ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.documents_form_image_number, index + 1),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
selectedImages = selectedImages.filter { it != image }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(Res.string.documents_form_remove_image),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (operationState is ApiResult.Error) {
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
showBlob = false
|
||||
) {
|
||||
Text(
|
||||
com.tt.honeyDue.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error messages (need to be defined outside onClick)
|
||||
val selectResidenceError = stringResource(Res.string.documents_form_select_residence_error)
|
||||
val titleRequiredError = stringResource(Res.string.documents_form_title_error)
|
||||
val itemRequiredError = stringResource(Res.string.documents_form_item_name_error)
|
||||
val providerRequiredError = stringResource(Res.string.documents_form_provider_error)
|
||||
|
||||
// Save Button
|
||||
OrganicPrimaryButton(
|
||||
text = when {
|
||||
isEditMode && isWarranty -> stringResource(Res.string.documents_form_update_warranty)
|
||||
isEditMode -> stringResource(Res.string.documents_form_update_document)
|
||||
isWarranty -> stringResource(Res.string.documents_form_add_warranty)
|
||||
else -> stringResource(Res.string.documents_form_add_document)
|
||||
},
|
||||
onClick = {
|
||||
// Validate
|
||||
var hasError = false
|
||||
|
||||
// Determine the actual residenceId to use
|
||||
val actualResidenceId = if (needsResidenceSelection) {
|
||||
if (selectedResidence == null) {
|
||||
residenceError = selectResidenceError
|
||||
hasError = true
|
||||
-1
|
||||
} else {
|
||||
selectedResidence!!.id
|
||||
}
|
||||
} else {
|
||||
residenceId ?: -1
|
||||
}
|
||||
|
||||
if (title.isBlank()) {
|
||||
titleError = titleRequiredError
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (isWarranty) {
|
||||
if (itemName.isBlank()) {
|
||||
itemNameError = itemRequiredError
|
||||
hasError = true
|
||||
}
|
||||
if (provider.isBlank()) {
|
||||
providerError = providerRequiredError
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = operationState !is ApiResult.Loading,
|
||||
isLoading = operationState is ApiResult.Loading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.tt.honeyDue.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.tt.honeyDue.ui.components.documents.DocumentsTabContent
|
||||
import com.tt.honeyDue.ui.subscription.UpgradeFeatureScreen
|
||||
import com.tt.honeyDue.utils.SubscriptionHelper
|
||||
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||
import com.tt.honeyDue.models.*
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
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.tt.honeyDue.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) {
|
||||
// Track screen view
|
||||
PostHogAnalytics.screen(AnalyticsEvents.DOCUMENTS_SCREEN_SHOWN)
|
||||
// Load all documents once - filtering happens client-side
|
||||
documentViewModel.loadAllDocuments(residenceId = residenceId)
|
||||
}
|
||||
|
||||
// Client-side filtering - no API calls on filter changes
|
||||
val filteredDocuments = remember(documentsState, selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
|
||||
val allDocuments = (documentsState as? com.tt.honeyDue.network.ApiResult.Success)?.data ?: emptyList()
|
||||
allDocuments.filter { document ->
|
||||
val matchesTab = if (selectedTab == DocumentTab.WARRANTIES) {
|
||||
document.documentType == "warranty"
|
||||
} else {
|
||||
document.documentType != "warranty"
|
||||
}
|
||||
val matchesCategory = selectedCategory == null || document.category == selectedCategory
|
||||
val matchesDocType = selectedDocType == null || document.documentType == selectedDocType
|
||||
val matchesActive = if (selectedTab == DocumentTab.WARRANTIES && showActiveOnly) {
|
||||
document.isActive == true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
matchesTab && matchesCategory && matchesDocType && matchesActive
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.documents_and_warranties), fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, stringResource(Res.string.common_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,
|
||||
stringResource(Res.string.documents_filter_active),
|
||||
tint = if (showActiveOnly) MaterialTheme.colorScheme.secondary else LocalContentColor.current
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter menu
|
||||
Box {
|
||||
IconButton(onClick = { showFiltersMenu = true }) {
|
||||
Icon(
|
||||
Icons.Default.FilterList,
|
||||
stringResource(Res.string.documents_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(stringResource(Res.string.documents_all_categories)) },
|
||||
onClick = {
|
||||
selectedCategory = null
|
||||
showFiltersMenu = false
|
||||
}
|
||||
)
|
||||
OrganicDivider()
|
||||
DocumentCategory.values().forEach { category ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(category.displayName) },
|
||||
onClick = {
|
||||
selectedCategory = category.value
|
||||
showFiltersMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.documents_all_types)) },
|
||||
onClick = {
|
||||
selectedDocType = null
|
||||
showFiltersMenu = false
|
||||
}
|
||||
)
|
||||
OrganicDivider()
|
||||
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(stringResource(Res.string.documents_warranties_tab)) },
|
||||
icon = { Icon(Icons.Default.VerifiedUser, null) }
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTab == DocumentTab.DOCUMENTS,
|
||||
onClick = { selectedTab = DocumentTab.DOCUMENTS },
|
||||
text = { Text(stringResource(Res.string.documents_documents_tab)) },
|
||||
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 {
|
||||
PostHogAnalytics.capture(
|
||||
AnalyticsEvents.DOCUMENTS_PAYWALL_SHOWN,
|
||||
mapOf("current_count" to currentCount)
|
||||
)
|
||||
showUpgradeDialog = true
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(Icons.Default.Add, stringResource(Res.string.documents_add_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
WarmGradientBackground {
|
||||
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 - use client-side filtered documents
|
||||
DocumentsTabContent(
|
||||
state = documentsState,
|
||||
filteredDocuments = filteredDocuments,
|
||||
isWarrantyTab = selectedTab == DocumentTab.WARRANTIES,
|
||||
onDocumentClick = onNavigateToDocumentDetail,
|
||||
onRetry = {
|
||||
// Reload all documents on pull-to-refresh
|
||||
documentViewModel.loadAllDocuments(residenceId = residenceId)
|
||||
},
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show upgrade dialog when user hits limit
|
||||
if (showUpgradeDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showUpgradeDialog = false },
|
||||
title = { Text(stringResource(Res.string.documents_upgrade_required)) },
|
||||
text = {
|
||||
Text(stringResource(Res.string.documents_upgrade_message))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showUpgradeDialog = false }) {
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user