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:
Trey t
2026-03-07 06:33:57 -06:00
parent 9c574c4343
commit 1e2adf7660
450 changed files with 1730 additions and 1788 deletions

View 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")
}
}
}
}
*/
}

View File

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

View File

@@ -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()
// }
// }
// }
//}

View File

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

View File

@@ -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"
}

View 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()
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}

View File

@@ -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"
}
}

View File

@@ -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
)

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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
)

View File

@@ -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>
)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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
)

View 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
)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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()}")
}
}

View File

@@ -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_")
}

View File

@@ -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>()
}

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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})"
}
}
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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 = {}
)

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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
)

View File

@@ -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 = {}
)

View File

@@ -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>

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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?

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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?

View File

@@ -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)
)
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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)
}

View File

@@ -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
}
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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))
}
}
}
)
}

View File

@@ -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)
}
}
)
}

View File

@@ -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")
}
}
)
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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))
}
}
}
)
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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
)

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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"
)
}
}
}

View File

@@ -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
)
)
}

View File

@@ -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]}"
}

View File

@@ -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")
}
}
}

View File

@@ -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 -> {}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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"
)
}
}

View File

@@ -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"
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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) }
)
}
}
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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