Updated Kotlin models, Android UI, and iOS UI to make all address fields optional for residences. Only the residence name is now required. Changes: - Kotlin: Made propertyType, streetAddress, city, stateProvince, postalCode, country nullable in Residence, ResidenceSummary, ResidenceWithTasks models - Kotlin: Updated navigation routes to handle nullable address fields - Android: Updated ResidenceFormScreen and ResidenceDetailScreen to handle nulls - iOS: Updated ResidenceFormView validation to only check name field - iOS: Updated PropertyHeaderCard and ResidenceCard to use optional binding for address field displays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
580 lines
25 KiB
Kotlin
580 lines
25 KiB
Kotlin
package com.example.mycrib
|
|
|
|
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.mycrib.android.ui.screens.AddResidenceScreen
|
|
import com.mycrib.android.ui.screens.EditResidenceScreen
|
|
import com.mycrib.android.ui.screens.EditTaskScreen
|
|
import com.mycrib.android.ui.screens.ForgotPasswordScreen
|
|
import com.mycrib.android.ui.screens.HomeScreen
|
|
import com.mycrib.android.ui.screens.LoginScreen
|
|
import com.mycrib.android.ui.screens.RegisterScreen
|
|
import com.mycrib.android.ui.screens.ResetPasswordScreen
|
|
import com.mycrib.android.ui.screens.ResidenceDetailScreen
|
|
import com.mycrib.android.ui.screens.ResidencesScreen
|
|
import com.mycrib.android.ui.screens.TasksScreen
|
|
import com.mycrib.android.ui.screens.VerifyEmailScreen
|
|
import com.mycrib.android.ui.screens.VerifyResetCodeScreen
|
|
import com.mycrib.android.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.mycrib.android.ui.screens.MainScreen
|
|
import com.mycrib.android.ui.screens.ProfileScreen
|
|
import com.mycrib.android.ui.theme.MyCribTheme
|
|
import com.mycrib.navigation.*
|
|
import com.mycrib.repository.LookupsRepository
|
|
import com.mycrib.shared.models.Residence
|
|
import com.mycrib.shared.models.TaskCategory
|
|
import com.mycrib.shared.models.TaskDetail
|
|
import com.mycrib.shared.models.TaskFrequency
|
|
import com.mycrib.shared.models.TaskPriority
|
|
import com.mycrib.shared.models.TaskStatus
|
|
import com.mycrib.shared.network.ApiResult
|
|
import com.mycrib.shared.network.AuthApi
|
|
import com.mycrib.storage.TokenStorage
|
|
|
|
import mycrib.composeapp.generated.resources.Res
|
|
import mycrib.composeapp.generated.resources.compose_multiplatform
|
|
|
|
@Composable
|
|
@Preview
|
|
fun App(
|
|
deepLinkResetToken: String? = null,
|
|
onClearDeepLinkToken: () -> Unit = {}
|
|
) {
|
|
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
|
|
var isVerified by remember { mutableStateOf(false) }
|
|
var isCheckingAuth by remember { mutableStateOf(true) }
|
|
val navController = rememberNavController()
|
|
|
|
// Check for stored token and verification status on app start
|
|
LaunchedEffect(Unit) {
|
|
val hasToken = TokenStorage.hasToken()
|
|
isLoggedIn = hasToken
|
|
|
|
if (hasToken) {
|
|
// Fetch current user to check verification status
|
|
val authApi = AuthApi()
|
|
val token = TokenStorage.getToken()
|
|
|
|
if (token != null) {
|
|
when (val result = authApi.getCurrentUser(token)) {
|
|
is ApiResult.Success -> {
|
|
isVerified = result.data.verified
|
|
LookupsRepository.initialize()
|
|
}
|
|
else -> {
|
|
// If fetching user fails, clear token and logout
|
|
TokenStorage.clearToken()
|
|
isLoggedIn = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
isCheckingAuth = false
|
|
}
|
|
|
|
MyCribTheme {
|
|
if (isCheckingAuth) {
|
|
// Show loading screen while checking auth
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.background
|
|
) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
CircularProgressIndicator()
|
|
}
|
|
}
|
|
return@MyCribTheme
|
|
}
|
|
|
|
val startDestination = when {
|
|
deepLinkResetToken != null -> ForgotPasswordRoute
|
|
!isLoggedIn -> LoginRoute
|
|
!isVerified -> VerifyEmailRoute
|
|
else -> MainRoute
|
|
}
|
|
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.background
|
|
) {
|
|
NavHost(
|
|
navController = navController,
|
|
startDestination = startDestination
|
|
) {
|
|
composable<LoginRoute> {
|
|
LoginScreen(
|
|
onLoginSuccess = { user ->
|
|
isLoggedIn = true
|
|
isVerified = user.verified
|
|
// Initialize lookups after successful login
|
|
LookupsRepository.initialize()
|
|
|
|
// Check if user is verified
|
|
if (user.verified) {
|
|
navController.navigate(MainRoute) {
|
|
popUpTo<LoginRoute> { inclusive = true }
|
|
}
|
|
} else {
|
|
navController.navigate(VerifyEmailRoute) {
|
|
popUpTo<LoginRoute> { inclusive = true }
|
|
}
|
|
}
|
|
},
|
|
onNavigateToRegister = {
|
|
navController.navigate(RegisterRoute)
|
|
},
|
|
onNavigateToForgotPassword = {
|
|
navController.navigate(ForgotPasswordRoute)
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<RegisterRoute> {
|
|
RegisterScreen(
|
|
onRegisterSuccess = {
|
|
isLoggedIn = true
|
|
isVerified = false
|
|
// Initialize lookups after successful registration
|
|
LookupsRepository.initialize()
|
|
navController.navigate(VerifyEmailRoute) {
|
|
popUpTo<RegisterRoute> { inclusive = true }
|
|
}
|
|
},
|
|
onNavigateBack = {
|
|
navController.popBackStack()
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<ForgotPasswordRoute> { backStackEntry ->
|
|
// Create shared ViewModel for all password reset screens
|
|
val parentEntry = remember(backStackEntry) {
|
|
navController.getBackStackEntry<ForgotPasswordRoute>()
|
|
}
|
|
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) {
|
|
PasswordResetViewModel(deepLinkToken = deepLinkResetToken)
|
|
}
|
|
|
|
ForgotPasswordScreen(
|
|
onNavigateBack = {
|
|
// Clear deep link token when navigating back to login
|
|
onClearDeepLinkToken()
|
|
navController.popBackStack()
|
|
},
|
|
onNavigateToVerify = {
|
|
navController.navigate(VerifyResetCodeRoute)
|
|
},
|
|
onNavigateToReset = {
|
|
navController.navigate(ResetPasswordRoute)
|
|
},
|
|
viewModel = passwordResetViewModel
|
|
)
|
|
}
|
|
|
|
composable<VerifyResetCodeRoute> { backStackEntry ->
|
|
// Use shared ViewModel from ForgotPasswordRoute
|
|
val parentEntry = remember(backStackEntry) {
|
|
navController.getBackStackEntry<ForgotPasswordRoute>()
|
|
}
|
|
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
|
|
|
|
VerifyResetCodeScreen(
|
|
onNavigateBack = {
|
|
navController.popBackStack()
|
|
},
|
|
onNavigateToReset = {
|
|
navController.navigate(ResetPasswordRoute)
|
|
},
|
|
viewModel = passwordResetViewModel
|
|
)
|
|
}
|
|
|
|
composable<ResetPasswordRoute> { backStackEntry ->
|
|
// Use shared ViewModel from ForgotPasswordRoute
|
|
val parentEntry = remember(backStackEntry) {
|
|
navController.getBackStackEntry<ForgotPasswordRoute>()
|
|
}
|
|
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
|
|
|
|
ResetPasswordScreen(
|
|
onPasswordResetSuccess = {
|
|
// Clear deep link token and navigate back to login after successful password reset
|
|
onClearDeepLinkToken()
|
|
navController.navigate(LoginRoute) {
|
|
popUpTo<ForgotPasswordRoute> { inclusive = true }
|
|
}
|
|
},
|
|
onNavigateBack = {
|
|
navController.popBackStack()
|
|
},
|
|
viewModel = passwordResetViewModel
|
|
)
|
|
}
|
|
|
|
composable<VerifyEmailRoute> {
|
|
VerifyEmailScreen(
|
|
onVerifySuccess = {
|
|
isVerified = true
|
|
navController.navigate(MainRoute) {
|
|
popUpTo<VerifyEmailRoute> { inclusive = true }
|
|
}
|
|
},
|
|
onLogout = {
|
|
// Clear token and lookups on logout
|
|
TokenStorage.clearToken()
|
|
LookupsRepository.clear()
|
|
isLoggedIn = false
|
|
isVerified = false
|
|
navController.navigate(LoginRoute) {
|
|
popUpTo<VerifyEmailRoute> { inclusive = true }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<MainRoute> {
|
|
MainScreen(
|
|
onLogout = {
|
|
// Clear token and lookups on logout
|
|
TokenStorage.clearToken()
|
|
LookupsRepository.clear()
|
|
isLoggedIn = false
|
|
isVerified = false
|
|
navController.navigate(LoginRoute) {
|
|
popUpTo<MainRoute> { inclusive = true }
|
|
}
|
|
},
|
|
onResidenceClick = { residenceId ->
|
|
navController.navigate(ResidenceDetailRoute(residenceId))
|
|
},
|
|
onAddResidence = {
|
|
navController.navigate(AddResidenceRoute)
|
|
},
|
|
onAddTask = {
|
|
// Tasks are added from within a residence
|
|
// Navigate to first residence or show message if no residences exist
|
|
// For now, this will be handled by the UI showing "add a property first"
|
|
},
|
|
onNavigateToEditResidence = { residence ->
|
|
navController.navigate(
|
|
EditResidenceRoute(
|
|
residenceId = residence.id,
|
|
name = residence.name,
|
|
propertyType = residence.propertyType?.toInt(),
|
|
streetAddress = residence.streetAddress,
|
|
apartmentUnit = residence.apartmentUnit,
|
|
city = residence.city,
|
|
stateProvince = residence.stateProvince,
|
|
postalCode = residence.postalCode,
|
|
country = residence.country,
|
|
bedrooms = residence.bedrooms,
|
|
bathrooms = residence.bathrooms,
|
|
squareFootage = residence.squareFootage,
|
|
lotSize = residence.lotSize,
|
|
yearBuilt = residence.yearBuilt,
|
|
description = residence.description,
|
|
isPrimary = residence.isPrimary,
|
|
ownerUserName = residence.ownerUsername,
|
|
createdAt = residence.createdAt,
|
|
updatedAt = residence.updatedAt,
|
|
owner = residence.owner
|
|
)
|
|
)
|
|
},
|
|
onNavigateToEditTask = { task ->
|
|
navController.navigate(
|
|
EditTaskRoute(
|
|
taskId = task.id,
|
|
residenceId = task.residence,
|
|
title = task.title,
|
|
description = task.description,
|
|
categoryId = task.category.id,
|
|
categoryName = task.category.name,
|
|
frequencyId = task.frequency.id,
|
|
frequencyName = task.frequency.name,
|
|
priorityId = task.priority.id,
|
|
priorityName = task.priority.name,
|
|
statusId = task.status?.id,
|
|
statusName = task.status?.name,
|
|
dueDate = task.dueDate,
|
|
estimatedCost = task.estimatedCost?.toString(),
|
|
createdAt = task.createdAt,
|
|
updatedAt = task.updatedAt
|
|
)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<HomeRoute> {
|
|
HomeScreen(
|
|
onNavigateToResidences = {
|
|
navController.navigate(MainRoute)
|
|
},
|
|
onNavigateToTasks = {
|
|
navController.navigate(TasksRoute)
|
|
},
|
|
onLogout = {
|
|
// Clear token and lookups on logout
|
|
TokenStorage.clearToken()
|
|
LookupsRepository.clear()
|
|
isLoggedIn = false
|
|
isVerified = false
|
|
navController.navigate(LoginRoute) {
|
|
popUpTo<HomeRoute> { inclusive = true }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<ResidencesRoute> { backStackEntry ->
|
|
// Get refresh flag from saved state (set when returning from add/edit)
|
|
val shouldRefresh = backStackEntry.savedStateHandle.get<Boolean>("refresh") ?: false
|
|
|
|
ResidencesScreen(
|
|
onResidenceClick = { residenceId ->
|
|
navController.navigate(ResidenceDetailRoute(residenceId))
|
|
},
|
|
onAddResidence = {
|
|
navController.navigate(AddResidenceRoute)
|
|
},
|
|
onNavigateToProfile = {
|
|
navController.navigate(ProfileRoute)
|
|
},
|
|
shouldRefresh = shouldRefresh,
|
|
onLogout = {
|
|
// Clear token and lookups on logout
|
|
TokenStorage.clearToken()
|
|
LookupsRepository.clear()
|
|
isLoggedIn = false
|
|
isVerified = false
|
|
navController.navigate(LoginRoute) {
|
|
popUpTo<HomeRoute> { inclusive = true }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<AddResidenceRoute> {
|
|
AddResidenceScreen(
|
|
onNavigateBack = {
|
|
navController.popBackStack()
|
|
},
|
|
onResidenceCreated = {
|
|
// Set refresh flag before navigating back
|
|
navController.previousBackStackEntry?.savedStateHandle?.set("refresh", true)
|
|
navController.popBackStack()
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<EditResidenceRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<EditResidenceRoute>()
|
|
EditResidenceScreen(
|
|
residence = Residence(
|
|
id = route.residenceId,
|
|
name = route.name,
|
|
propertyType = route.propertyType.toString(), // Will be fetched from lookups
|
|
streetAddress = route.streetAddress,
|
|
apartmentUnit = route.apartmentUnit,
|
|
city = route.city,
|
|
stateProvince = route.stateProvince,
|
|
postalCode = route.postalCode,
|
|
country = route.country,
|
|
bedrooms = route.bedrooms,
|
|
bathrooms = route.bathrooms,
|
|
squareFootage = route.squareFootage,
|
|
lotSize = route.lotSize,
|
|
yearBuilt = route.yearBuilt,
|
|
description = route.description,
|
|
purchaseDate = null,
|
|
purchasePrice = null,
|
|
isPrimary = route.isPrimary,
|
|
ownerUsername = route.ownerUserName,
|
|
owner = route.owner,
|
|
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.propertyType?.toInt(),
|
|
streetAddress = residence.streetAddress,
|
|
apartmentUnit = residence.apartmentUnit,
|
|
city = residence.city,
|
|
stateProvince = residence.stateProvince,
|
|
postalCode = residence.postalCode,
|
|
country = residence.country,
|
|
bedrooms = residence.bedrooms,
|
|
bathrooms = residence.bathrooms,
|
|
squareFootage = residence.squareFootage,
|
|
lotSize = residence.lotSize,
|
|
yearBuilt = residence.yearBuilt,
|
|
description = residence.description,
|
|
isPrimary = residence.isPrimary,
|
|
ownerUserName = residence.ownerUsername,
|
|
createdAt = residence.createdAt,
|
|
updatedAt = residence.updatedAt,
|
|
owner = residence.owner
|
|
)
|
|
)
|
|
},
|
|
onNavigateToEditTask = { task ->
|
|
navController.navigate(
|
|
EditTaskRoute(
|
|
taskId = task.id,
|
|
residenceId = task.residence,
|
|
title = task.title,
|
|
description = task.description,
|
|
categoryId = task.category.id,
|
|
categoryName = task.category.name,
|
|
frequencyId = task.frequency.id,
|
|
frequencyName = task.frequency.name,
|
|
priorityId = task.priority.id,
|
|
priorityName = task.priority.name,
|
|
statusId = task.status?.id,
|
|
statusName = task.status?.name,
|
|
dueDate = task.dueDate,
|
|
estimatedCost = task.estimatedCost?.toString(),
|
|
createdAt = task.createdAt,
|
|
updatedAt = task.updatedAt
|
|
)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
composable<EditTaskRoute> { backStackEntry ->
|
|
val route = backStackEntry.toRoute<EditTaskRoute>()
|
|
EditTaskScreen(
|
|
task = TaskDetail(
|
|
id = route.taskId,
|
|
residence = route.residenceId,
|
|
title = route.title,
|
|
description = route.description,
|
|
category = TaskCategory(route.categoryId, route.categoryName),
|
|
frequency = TaskFrequency(
|
|
route.frequencyId, route.frequencyName, "", route.frequencyName,
|
|
daySpan = 0,
|
|
notifyDays = 0
|
|
),
|
|
priority = TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
|
|
status = route.statusId?.let {
|
|
TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "")
|
|
},
|
|
dueDate = route.dueDate,
|
|
estimatedCost = route.estimatedCost?.toDoubleOrNull(),
|
|
createdAt = route.createdAt,
|
|
updatedAt = route.updatedAt,
|
|
nextScheduledDate = null,
|
|
showCompletedButton = false,
|
|
completions = emptyList()
|
|
),
|
|
onNavigateBack = { navController.popBackStack() },
|
|
onTaskUpdated = { navController.popBackStack() }
|
|
)
|
|
}
|
|
|
|
composable<ProfileRoute> {
|
|
ProfileScreen(
|
|
onNavigateBack = {
|
|
navController.popBackStack()
|
|
},
|
|
onLogout = {
|
|
// Clear token and lookups on logout
|
|
TokenStorage.clearToken()
|
|
LookupsRepository.clear()
|
|
isLoggedIn = false
|
|
isVerified = false
|
|
navController.navigate(LoginRoute) {
|
|
popUpTo<ProfileRoute> { inclusive = true }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
MaterialTheme {
|
|
var showContent by remember { mutableStateOf(false) }
|
|
Column(
|
|
modifier = Modifier
|
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
.safeContentPadding()
|
|
.fillMaxSize(),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Button(onClick = { showContent = !showContent }) {
|
|
Text("Click me!")
|
|
}
|
|
AnimatedVisibility(showContent) {
|
|
val greeting = remember { Greeting().greet() }
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
|
Text("Compose: $greeting")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
*/
|
|
} |