From e24d1d8559bd7fd6dc4c066a951b16772d97fc5c Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 6 Nov 2025 09:25:21 -0600 Subject: [PATCH] wip --- .../kotlin/com/example/mycrib/App.kt | 78 +++- .../com/example/mycrib/models/CustomTask.kt | 9 + .../com/example/mycrib/navigation/Routes.kt | 12 + .../com/example/mycrib/network/TaskApi.kt | 25 +- .../mycrib/ui/components/task/TaskCard.kt | 67 ++- .../mycrib/ui/screens/AllTasksScreen.kt | 388 ++++++++++++++++++ .../example/mycrib/ui/screens/LoginScreen.kt | 12 +- .../example/mycrib/ui/screens/MainScreen.kt | 149 +++++++ .../mycrib/ui/screens/ProfileScreen.kt | 2 +- .../ui/screens/ResidenceDetailScreen.kt | 15 +- .../mycrib/ui/screens/ResidencesScreen.kt | 7 +- .../example/mycrib/ui/screens/TasksScreen.kt | 244 ++++++++++- .../example/mycrib/viewmodel/TaskViewModel.kt | 26 +- iosApp/iosApp/HomeScreenView.swift | 4 +- iosApp/iosApp/Login/LoginView.swift | 42 +- iosApp/iosApp/Login/LoginViewModel.swift | 68 ++- iosApp/iosApp/MainTabView.swift | 12 +- iosApp/iosApp/Profile/ProfileViewModel.swift | 5 +- iosApp/iosApp/Register/RegisterView.swift | 2 +- .../iosApp/Register/RegisterViewModel.swift | 3 +- .../Residence/ResidenceDetailView.swift | 22 +- .../iosApp/Residence/ResidenceViewModel.swift | 3 +- iosApp/iosApp/Subviews/Task/TaskCard.swift | 46 ++- .../iosApp/Subviews/Task/TasksSection.swift | 18 +- iosApp/iosApp/Task/AllTasksView.swift | 267 ++++++++++++ iosApp/iosApp/Task/CompleteTaskView.swift | 300 ++++++++++++++ iosApp/iosApp/Task/TaskViewModel.swift | 74 +++- .../VerifyEmail/VerifyEmailViewModel.swift | 3 +- iosApp/iosApp/iOSApp.swift | 6 + 29 files changed, 1806 insertions(+), 103 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt create mode 100644 iosApp/iosApp/Task/AllTasksView.swift create mode 100644 iosApp/iosApp/Task/CompleteTaskView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index 383460b..cdf2572 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -31,6 +31,7 @@ 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.navigation.* import com.mycrib.repository.LookupsRepository import com.mycrib.shared.models.Residence @@ -94,7 +95,7 @@ fun App() { val startDestination = when { !isLoggedIn -> LoginRoute !isVerified -> VerifyEmailRoute - else -> ResidencesRoute + else -> MainRoute } Surface( @@ -115,7 +116,7 @@ fun App() { // Check if user is verified if (user.verified) { - navController.navigate(ResidencesRoute) { + navController.navigate(MainRoute) { popUpTo { inclusive = true } } } else { @@ -151,7 +152,7 @@ fun App() { VerifyEmailScreen( onVerifySuccess = { isVerified = true - navController.navigate(ResidencesRoute) { + navController.navigate(MainRoute) { popUpTo { inclusive = true } } }, @@ -168,10 +169,79 @@ fun App() { ) } + composable { + MainScreen( + onLogout = { + // Clear token and lookups on logout + TokenStorage.clearToken() + LookupsRepository.clear() + isLoggedIn = false + isVerified = false + navController.navigate(LoginRoute) { + popUpTo { inclusive = true } + } + }, + onResidenceClick = { residenceId -> + navController.navigate(ResidenceDetailRoute(residenceId)) + }, + onAddResidence = { + navController.navigate(AddResidenceRoute) + }, + 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, + createdAt = task.createdAt, + updatedAt = task.updatedAt + ) + ) + } + ) + } + composable { HomeScreen( onNavigateToResidences = { - navController.navigate(ResidencesRoute) + navController.navigate(MainRoute) }, onNavigateToTasks = { navController.navigate(TasksRoute) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index 894224a..fe8c73d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -88,6 +88,15 @@ data class CategorizedTaskSummary( val done: Int ) +@Serializable +data class AllTasksResponse( + @SerialName("days_threshold") val daysThreshold: Int, + val summary: CategorizedTaskSummary, + @SerialName("upcoming_tasks") val upcomingTasks: List, + @SerialName("in_progress_tasks") val inProgressTasks: List, + @SerialName("done_tasks") val doneTasks: List +) + @Serializable data class TaskCancelResponse( val message: String, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index 1c2bdd9..23c9e2e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -73,3 +73,15 @@ object TasksRoute @Serializable object ProfileRoute + +@Serializable +object MainRoute + +@Serializable +object MainTabResidencesRoute + +@Serializable +object MainTabTasksRoute + +@Serializable +object MainTabProfileRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt index d709e53..48e78af 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -9,15 +9,18 @@ import io.ktor.http.* class TaskApi(private val client: HttpClient = ApiClient.httpClient) { private val baseUrl = ApiClient.getBaseUrl() - suspend fun getTasks(token: String): ApiResult> { + suspend fun getTasks( + token: String, + days: Int = 30 + ): ApiResult { return try { val response = client.get("$baseUrl/tasks/") { header("Authorization", "Token $token") + parameter("days", days) } if (response.status.isSuccess()) { - val data: PaginatedResponse = response.body() - ApiResult.Success(data.results) + ApiResult.Success(response.body()) } else { ApiResult.Error("Failed to fetch tasks", response.status.value) } @@ -146,4 +149,20 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + suspend fun markInProgress(token: String, id: Int): ApiResult { + return try { + val response = client.post("$baseUrl/tasks/$id/mark-in-progress/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to mark task as in progress", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index 1dffb47..1b7c687 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -23,7 +23,8 @@ fun TaskCard( onCompleteClick: (() -> Unit)?, onEditClick: () -> Unit, onCancelClick: (() -> Unit)?, - onUncancelClick: (() -> Unit)? + onUncancelClick: (() -> Unit)?, + onMarkInProgressClick: (() -> Unit)? = null ) { Card( modifier = Modifier.fillMaxWidth(), @@ -240,25 +241,57 @@ fun TaskCard( } } - // Show complete task button based on API logic - if (task.showCompletedButton && onCompleteClick != null) { + // Show complete task button and mark in progress button + if ((task.showCompletedButton && onCompleteClick != null) || (onMarkInProgressClick != null && task.status?.name != "in_progress")) { Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = onCompleteClick, + Row( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Complete Task", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) + // Mark In Progress button + if (onMarkInProgressClick != null && task.status?.name != "in_progress") { + Button( + onClick = onMarkInProgressClick, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + "In Progress", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + + // Complete Task button + if (task.showCompletedButton && onCompleteClick != null) { + Button( + onClick = onCompleteClick, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + "Complete", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt new file mode 100644 index 0000000..84715ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt @@ -0,0 +1,388 @@ +package com.mycrib.android.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.mycrib.android.ui.components.CompleteTaskDialog +import com.mycrib.android.ui.components.task.TaskCard +import com.mycrib.android.viewmodel.TaskCompletionViewModel +import com.mycrib.android.viewmodel.TaskViewModel +import com.mycrib.shared.models.TaskDetail +import com.mycrib.shared.network.ApiResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AllTasksScreen( + onNavigateToEditTask: (TaskDetail) -> Unit, + viewModel: TaskViewModel = viewModel { TaskViewModel() }, + taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() } +) { + val tasksState by viewModel.tasksState.collectAsState() + val completionState by taskCompletionViewModel.createCompletionState.collectAsState() + var showInProgressTasks by remember { mutableStateOf(false) } + var showDoneTasks by remember { mutableStateOf(false) } + var showCompleteDialog by remember { mutableStateOf(false) } + var selectedTask by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.loadTasks() + } + + // Handle completion success + LaunchedEffect(completionState) { + when (completionState) { + is ApiResult.Success -> { + showCompleteDialog = false + selectedTask = null + taskCompletionViewModel.resetCreateState() + viewModel.loadTasks() + } + else -> {} + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + "All Tasks", + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + when (tasksState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + CircularProgressIndicator() + } + } + is ApiResult.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Column( + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = "Error: ${(tasksState as ApiResult.Error).message}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { viewModel.loadTasks() }) { + Text("Retry") + } + } + } + } + is ApiResult.Success -> { + val taskData = (tasksState as ApiResult.Success).data + val hasNoTasks = taskData.upcomingTasks.isEmpty() && + taskData.inProgressTasks.isEmpty() && + taskData.doneTasks.isEmpty() + + if (hasNoTasks) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Column( + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + Text( + "No tasks yet", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Add a task to a residence to get started", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 96.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Task summary pills + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TaskSummaryPill( + count = taskData.summary.upcoming, + label = "Upcoming", + color = MaterialTheme.colorScheme.primary + ) + TaskSummaryPill( + count = taskData.summary.inProgress, + label = "In Progress", + color = MaterialTheme.colorScheme.tertiary + ) + TaskSummaryPill( + count = taskData.summary.done, + label = "Done", + color = MaterialTheme.colorScheme.secondary + ) + } + } + + // Upcoming tasks header + if (taskData.upcomingTasks.isNotEmpty()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + Icons.Default.CalendarToday, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Upcoming (${taskData.upcomingTasks.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + + // Upcoming tasks + items(taskData.upcomingTasks) { task -> + TaskCard( + task = task, + onCompleteClick = { + selectedTask = task + showCompleteDialog = true + }, + onEditClick = { onNavigateToEditTask(task) }, + onCancelClick = { /* TODO */ }, + onUncancelClick = null, + onMarkInProgressClick = { + viewModel.markInProgress(task.id) { success -> + if (success) { + viewModel.loadTasks() + } + } + } + ) + } + + // In Progress section (collapsible) + if (taskData.inProgressTasks.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { showInProgressTasks = !showInProgressTasks } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + text = "In Progress (${taskData.inProgressTasks.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + Icon( + if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showInProgressTasks) "Collapse" else "Expand" + ) + } + } + } + + if (showInProgressTasks) { + items(taskData.inProgressTasks) { task -> + TaskCard( + task = task, + onCompleteClick = { + selectedTask = task + showCompleteDialog = true + }, + onEditClick = { onNavigateToEditTask(task) }, + onCancelClick = { /* TODO */ }, + onUncancelClick = null, + onMarkInProgressClick = null + ) + } + } + } + + // Done section (collapsible) + if (taskData.doneTasks.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { showDoneTasks = !showDoneTasks } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary + ) + Text( + text = "Done (${taskData.doneTasks.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + Icon( + if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showDoneTasks) "Collapse" else "Expand" + ) + } + } + } + + if (showDoneTasks) { + items(taskData.doneTasks) { task -> + TaskCard( + task = task, + onCompleteClick = null, + onEditClick = { onNavigateToEditTask(task) }, + onCancelClick = null, + onUncancelClick = null, + onMarkInProgressClick = null + ) + } + } + } + } + } + } + + else -> {} + } + } + + if (showCompleteDialog && selectedTask != null) { + CompleteTaskDialog( + taskId = selectedTask!!.id, + taskTitle = selectedTask!!.title, + onDismiss = { + showCompleteDialog = false + selectedTask = null + taskCompletionViewModel.resetCreateState() + }, + onComplete = { request, images -> + if (images.isNotEmpty()) { + taskCompletionViewModel.createTaskCompletionWithImages( + request = request, + images = images.map { it.bytes }, + imageFileNames = images.map { it.fileName } + ) + } else { + taskCompletionViewModel.createTaskCompletion(request) + } + } + ) + } +} + +@Composable +private fun TaskSummaryPill( + count: Int, + label: String, + color: androidx.compose.ui.graphics.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 = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + color = color, + fontWeight = FontWeight.Bold + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt index 3720b59..af2e0be 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.mycrib.android.ui.components.auth.AuthHeader @@ -27,6 +28,7 @@ fun LoginScreen( ) { var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } val loginState by viewModel.loginState.collectAsState() // Handle login state changes @@ -97,9 +99,17 @@ fun LoginScreen( leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), shape = RoundedCornerShape(12.dp) ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt new file mode 100644 index 0000000..466e31a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt @@ -0,0 +1,149 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.mycrib.navigation.* +import com.mycrib.repository.LookupsRepository +import com.mycrib.shared.models.Residence +import com.mycrib.storage.TokenStorage + +@Composable +fun MainScreen( + onLogout: () -> Unit, + onResidenceClick: (Int) -> Unit, + onAddResidence: () -> Unit, + onNavigateToEditResidence: (Residence) -> Unit, + onNavigateToEditTask: (com.mycrib.shared.models.TaskDetail) -> Unit +) { + var selectedTab by remember { mutableStateOf(0) } + val navController = rememberNavController() + + Scaffold( + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + NavigationBar( + modifier = Modifier + .widthIn(max = 500.dp) + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(20.dp) + ), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 0.dp + ) { + NavigationBarItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Residences") }, + label = { Text("Residences") }, + selected = selectedTab == 0, + onClick = { + selectedTab = 0 + navController.navigate(MainTabResidencesRoute) { + popUpTo(MainTabResidencesRoute) { inclusive = true } + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + NavigationBarItem( + icon = { Icon(Icons.Default.CheckCircle, contentDescription = "Tasks") }, + label = { Text("Tasks") }, + selected = selectedTab == 1, + onClick = { + selectedTab = 1 + navController.navigate(MainTabTasksRoute) { + popUpTo(MainTabResidencesRoute) { inclusive = false } + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = selectedTab == 2, + onClick = { + selectedTab = 2 + navController.navigate(MainTabProfileRoute) { + popUpTo(MainTabResidencesRoute) { inclusive = false } + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = MainTabResidencesRoute, + modifier = Modifier.fillMaxSize() + ) { + composable { + Box(modifier = Modifier.fillMaxSize()) { + ResidencesScreen( + onResidenceClick = onResidenceClick, + onAddResidence = onAddResidence, + onLogout = onLogout, + onNavigateToProfile = { + selectedTab = 2 + navController.navigate(MainTabProfileRoute) + } + ) + } + } + + composable { + Box(modifier = Modifier.fillMaxSize()) { + AllTasksScreen( + onNavigateToEditTask = onNavigateToEditTask + ) + } + } + + composable { + Box(modifier = Modifier.fillMaxSize()) { + ProfileScreen( + onNavigateBack = { + selectedTab = 0 + navController.navigate(MainTabResidencesRoute) + }, + onLogout = onLogout + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt index ae04eeb..dec176b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt @@ -116,7 +116,7 @@ fun ProfileScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(24.dp), + .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 96.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp) ) { diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index 90a4865..46fa863 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -441,7 +441,14 @@ fun ResidenceDetailScreen( onCancelClick = { residenceViewModel.cancelTask(task.id) }, - onUncancelClick = null + onUncancelClick = null, + onMarkInProgressClick = { + taskViewModel.markInProgress(task.id) { success -> + if (success) { + residenceViewModel.loadResidenceTasks(residenceId) + } + } + } ) } @@ -494,7 +501,8 @@ fun ResidenceDetailScreen( onCancelClick = { residenceViewModel.cancelTask(task.id) }, - onUncancelClick = null + onUncancelClick = null, + onMarkInProgressClick = null ) } } @@ -546,7 +554,8 @@ fun ResidenceDetailScreen( onCancelClick = null, onUncancelClick = { residenceViewModel.uncancelTask(task.id) - } + }, + onMarkInProgressClick = null ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index ef208b9..360e1fb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -144,7 +144,12 @@ fun ResidencesScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 96.dp + ), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Summary Card diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt index f5a58c5..81aa68d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt @@ -10,7 +10,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.mycrib.android.ui.components.task.SimpleTaskListItem +import com.mycrib.android.ui.components.CompleteTaskDialog +import com.mycrib.android.ui.components.task.TaskCard +import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskViewModel import com.mycrib.shared.network.ApiResult @@ -18,18 +20,37 @@ import com.mycrib.shared.network.ApiResult @Composable fun TasksScreen( onNavigateBack: () -> Unit, - viewModel: TaskViewModel = viewModel { TaskViewModel() } + viewModel: TaskViewModel = viewModel { TaskViewModel() }, + taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() } ) { val tasksState by viewModel.tasksState.collectAsState() + val completionState by taskCompletionViewModel.createCompletionState.collectAsState() + var showInProgressTasks by remember { mutableStateOf(false) } + var showDoneTasks by remember { mutableStateOf(false) } + var showCompleteDialog by remember { mutableStateOf(false) } + var selectedTask by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.loadTasks() } + // Handle completion success + LaunchedEffect(completionState) { + when (completionState) { + is ApiResult.Success -> { + showCompleteDialog = false + selectedTask = null + taskCompletionViewModel.resetCreateState() + viewModel.loadTasks() + } + else -> {} + } + } + Scaffold( topBar = { TopAppBar( - title = { Text("Tasks") }, + title = { Text("All Tasks") }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") @@ -74,8 +95,12 @@ fun TasksScreen( } } is ApiResult.Success -> { - val tasks = (tasksState as ApiResult.Success).data - if (tasks.isEmpty()) { + val taskData = (tasksState as ApiResult.Success).data + val hasNoTasks = taskData.upcomingTasks.isEmpty() && + taskData.inProgressTasks.isEmpty() && + taskData.doneTasks.isEmpty() + + if (hasNoTasks) { Box( modifier = Modifier .fillMaxSize() @@ -90,18 +115,157 @@ fun TasksScreen( .fillMaxSize() .padding(paddingValues), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(tasks) { task -> - SimpleTaskListItem( - title = task.title, - description = task.description, - priority = task.priority, - status = task.status, - dueDate = task.dueDate, - isOverdue = task.isOverdue == true + // Task summary pills + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TaskPill( + count = taskData.summary.upcoming, + label = "Upcoming", + color = MaterialTheme.colorScheme.primary + ) + TaskPill( + count = taskData.summary.inProgress, + label = "In Progress", + color = MaterialTheme.colorScheme.tertiary + ) + TaskPill( + count = taskData.summary.done, + label = "Done", + color = MaterialTheme.colorScheme.secondary + ) + } + } + + // Upcoming tasks header + if (taskData.upcomingTasks.isNotEmpty()) { + item { + Text( + text = "Upcoming (${taskData.upcomingTasks.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + + // Upcoming tasks + items(taskData.upcomingTasks) { task -> + TaskCard( + task = task, + onCompleteClick = { + selectedTask = task + showCompleteDialog = true + }, + onEditClick = { }, + onCancelClick = { }, + onUncancelClick = { } ) } + + // In Progress section (collapsible) + if (taskData.inProgressTasks.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { showInProgressTasks = !showInProgressTasks } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + text = "In Progress (${taskData.inProgressTasks.size})", + style = MaterialTheme.typography.titleMedium + ) + } + Icon( + if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showInProgressTasks) "Collapse" else "Expand" + ) + } + } + } + + if (showInProgressTasks) { + items(taskData.inProgressTasks) { task -> + TaskCard( + task = task, + onCompleteClick = { + selectedTask = task + showCompleteDialog = true + }, + onEditClick = { /* TODO */ }, + onCancelClick = {}, + onUncancelClick = {} + ) + } + } + } + + // Done section (collapsible) + if (taskData.doneTasks.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { showDoneTasks = !showDoneTasks } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary + ) + Text( + text = "Done (${taskData.doneTasks.size})", + style = MaterialTheme.typography.titleMedium + ) + } + Icon( + if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showDoneTasks) "Collapse" else "Expand" + ) + } + } + } + + if (showDoneTasks) { + items(taskData.doneTasks) { task -> + TaskCard( + task = task, + onCompleteClick = { /* TODO */ }, + onEditClick = { /* TODO */ }, + onUncancelClick = {}, + onCancelClick = {} + ) + } + } + } } } } @@ -109,4 +273,56 @@ fun TasksScreen( else -> {} } } + + if (showCompleteDialog && selectedTask != null) { + CompleteTaskDialog( + taskId = selectedTask!!.id, + taskTitle = selectedTask!!.title, + onDismiss = { + showCompleteDialog = false + selectedTask = null + taskCompletionViewModel.resetCreateState() + }, + onComplete = { request, images -> + if (images.isNotEmpty()) { + taskCompletionViewModel.createTaskCompletionWithImages( + request = request, + images = images.map { it.bytes }, + imageFileNames = images.map { it.fileName } + ) + } else { + taskCompletionViewModel.createTaskCompletion(request) + } + } + ) + } +} + +@Composable +private fun TaskPill( + count: Int, + label: String, + color: androidx.compose.ui.graphics.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 = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + color = color + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color + ) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt index 35f4851..fe89a54 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -2,6 +2,7 @@ package com.mycrib.android.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.AllTasksResponse import com.mycrib.shared.models.CustomTask import com.mycrib.shared.models.TaskCreateRequest import com.mycrib.shared.models.TasksByResidenceResponse @@ -15,8 +16,8 @@ import kotlinx.coroutines.launch class TaskViewModel : ViewModel() { private val taskApi = TaskApi() - private val _tasksState = MutableStateFlow>>(ApiResult.Loading) - val tasksState: StateFlow>> = _tasksState + private val _tasksState = MutableStateFlow>(ApiResult.Loading) + val tasksState: StateFlow> = _tasksState private val _tasksByResidenceState = MutableStateFlow>(ApiResult.Loading) val tasksByResidenceState: StateFlow> = _tasksByResidenceState @@ -63,4 +64,25 @@ class TaskViewModel : ViewModel() { fun resetAddTaskState() { _taskAddNewCustomTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it } + + fun markInProgress(taskId: Int, onComplete: (Boolean) -> Unit) { + viewModelScope.launch { + val token = TokenStorage.getToken() + if (token != null) { + when (val result = taskApi.markInProgress(token, taskId)) { + is ApiResult.Success -> { + onComplete(true) + } + is ApiResult.Error -> { + onComplete(false) + } + else -> { + onComplete(false) + } + } + } else { + onComplete(false) + } + } + } } diff --git a/iosApp/iosApp/HomeScreenView.swift b/iosApp/iosApp/HomeScreenView.swift index 4531e61..169aebd 100644 --- a/iosApp/iosApp/HomeScreenView.swift +++ b/iosApp/iosApp/HomeScreenView.swift @@ -31,11 +31,11 @@ struct HomeScreenView: View { ) } - NavigationLink(destination: Text("Tasks (Coming Soon)")) { + NavigationLink(destination: AllTasksView()) { HomeNavigationCard( icon: "checkmark.circle.fill", title: "Tasks", - subtitle: "View and manage tasks" + subtitle: "View and manage all tasks" ) } } diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index 4cc483c..c61b4c7 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -6,6 +6,7 @@ struct LoginView: View { @State private var showingRegister = false @State private var showMainTab = false @State private var showVerification = false + @State private var isPasswordVisible = false enum Field { case username, password @@ -42,13 +43,40 @@ struct LoginView: View { .onSubmit { focusedField = .password } - - SecureField("Password", text: $viewModel.password) - .focused($focusedField, equals: .password) - .submitLabel(.go) - .onSubmit { - viewModel.login() + .onChange(of: viewModel.username) { _, _ in + viewModel.clearError() } + + HStack { + if isPasswordVisible { + TextField("Password", text: $viewModel.password) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .password) + .submitLabel(.go) + .onSubmit { + viewModel.login() + } + } else { + SecureField("Password", text: $viewModel.password) + .focused($focusedField, equals: .password) + .submitLabel(.go) + .onSubmit { + viewModel.login() + } + } + + Button(action: { + isPasswordVisible.toggle() + }) { + Image(systemName: isPasswordVisible ? "eye.slash.fill" : "eye.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .onChange(of: viewModel.password) { _, _ in + viewModel.clearError() + } } header: { Text("Account Information") } @@ -97,8 +125,6 @@ struct LoginView: View { } .listRowBackground(Color.clear) } - .navigationTitle("Welcome Back") - .navigationBarTitleDisplayMode(.large) .onChange(of: viewModel.isAuthenticated) { _, isAuth in if isAuth { print("isAuthenticated changed to true, isVerified = \(viewModel.isVerified)") diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 7a89b68..7e869d2 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -20,11 +20,8 @@ class LoginViewModel: ObservableObject { // MARK: - Initialization init() { self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) - self.tokenStorage = TokenStorage() - - // Initialize TokenStorage with platform-specific manager - self.tokenStorage.initialize(manager: TokenManager.init()) - + self.tokenStorage = TokenStorage.shared + // Check if user is already logged in checkAuthenticationStatus() } @@ -53,15 +50,21 @@ class LoginViewModel: ObservableObject { self.handleSuccess(results: successResult) return } - + + if let errorResult = result as? ApiResultError { + self.handleApiError(errorResult: errorResult) + return + } + if let error = error { self.handleError(error: error) return } - + self.isLoading = false self.isAuthenticated = false - print("uknown error") + self.errorMessage = "Login failed. Please try again." + print("unknown error") } } } @@ -70,9 +73,25 @@ class LoginViewModel: ObservableObject { func handleError(error: any Error) { self.isLoading = false self.isAuthenticated = false + self.errorMessage = error.localizedDescription print(error) } - + + @MainActor + func handleApiError(errorResult: ApiResultError) { + self.isLoading = false + self.isAuthenticated = false + + // Check for specific error codes + if errorResult.code?.intValue == 401 || errorResult.code?.intValue == 400 { + self.errorMessage = "Invalid username or password" + } else { + self.errorMessage = errorResult.message + } + + print("API Error: \(errorResult.message)") + } + @MainActor func handleSuccess(results: ApiResultSuccess) { if let token = results.data?.token, @@ -133,12 +152,37 @@ class LoginViewModel: ObservableObject { // MARK: - Private Methods private func checkAuthenticationStatus() { - isAuthenticated = tokenStorage.hasToken() + guard let token = tokenStorage.getToken() else { + isAuthenticated = false + isVerified = false + return + } - // If already authenticated, initialize lookups - if isAuthenticated { + // Fetch current user to check verification status + authApi.getCurrentUser(token: token) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleAuthCheck(user: successResult.data!) + } else { + // Token invalid or expired, clear it + self.tokenStorage.clearToken() + self.isAuthenticated = false + self.isVerified = false + } + } + } + + @MainActor + private func handleAuthCheck(user: User) { + self.currentUser = user + self.isVerified = user.verified + self.isAuthenticated = true + + // Initialize lookups if verified + if user.verified { LookupsManager.shared.initialize() } + + print("Auth check - User: \(user.username), Verified: \(user.verified)") } } diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 42a622d..79b824c 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -14,11 +14,13 @@ struct MainTabView: View { } .tag(0) - Text("Tasks (Coming Soon)") - .tabItem { - Label("Tasks", systemImage: "checkmark.circle.fill") - } - .tag(1) + NavigationView { + AllTasksView() + } + .tabItem { + Label("Tasks", systemImage: "checkmark.circle.fill") + } + .tag(1) NavigationView { ProfileTabView() diff --git a/iosApp/iosApp/Profile/ProfileViewModel.swift b/iosApp/iosApp/Profile/ProfileViewModel.swift index db61b6a..45cf6be 100644 --- a/iosApp/iosApp/Profile/ProfileViewModel.swift +++ b/iosApp/iosApp/Profile/ProfileViewModel.swift @@ -20,10 +20,7 @@ class ProfileViewModel: ObservableObject { // MARK: - Initialization init() { self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) - self.tokenStorage = TokenStorage() - - // Initialize TokenStorage with platform-specific manager - self.tokenStorage.initialize(manager: TokenManager.init()) + self.tokenStorage = TokenStorage.shared // Load current user data loadCurrentUser() diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 806a84f..a405e51 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -121,7 +121,7 @@ struct RegisterView: View { }, onLogout: { // Logout and return to login screen - TokenManager().clearToken() + TokenStorage.shared.clearToken() LookupsManager.shared.clear() dismiss() } diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index 2d82ba9..38ccd6e 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -20,8 +20,7 @@ class RegisterViewModel: ObservableObject { // MARK: - Initialization init() { self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) - self.tokenStorage = TokenStorage() - self.tokenStorage.initialize(manager: TokenManager.init()) + self.tokenStorage = TokenStorage.shared } // MARK: - Public Methods diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index ad69307..294c4c8 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -14,6 +14,8 @@ struct ResidenceDetailView: View { @State private var selectedTaskForEdit: TaskDetail? @State private var showInProgressTasks = false @State private var showDoneTasks = false + @State private var showCompleteTask = false + @State private var selectedTaskForComplete: TaskDetail? var body: some View { ZStack { @@ -53,6 +55,17 @@ struct ResidenceDetailView: View { taskViewModel.uncancelTask(id: task.id) { _ in loadResidenceTasks() } + }, + onMarkInProgress: { task in + taskViewModel.markInProgress(id: task.id) { success in + if success { + loadResidenceTasks() + } + } + }, + onCompleteTask: { task in + selectedTaskForComplete = task + showCompleteTask = true } ) .padding(.horizontal) @@ -102,6 +115,13 @@ struct ResidenceDetailView: View { EditTaskView(task: task, isPresented: $showEditTask) } } + .sheet(isPresented: $showCompleteTask) { + if let task = selectedTaskForComplete { + CompleteTaskView(task: task, isPresented: $showCompleteTask) { + loadResidenceTasks() + } + } + } .onChange(of: showAddTask) { isShowing in if !isShowing { loadResidenceTasks() @@ -128,7 +148,7 @@ struct ResidenceDetailView: View { } private func loadResidenceTasks() { - guard let token = TokenStorage().getToken() else { return } + guard let token = TokenStorage.shared.getToken() else { return } isLoadingTasks = true tasksError = nil diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index bb8ffb2..6421fcf 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -18,8 +18,7 @@ class ResidenceViewModel: ObservableObject { // MARK: - Initialization init() { self.residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient()) - self.tokenStorage = TokenStorage() - self.tokenStorage.initialize(manager: TokenManager.init()) + self.tokenStorage = TokenStorage.shared } // MARK: - Public Methods diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 010660a..0adceb1 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -6,6 +6,8 @@ struct TaskCard: View { let onEdit: () -> Void let onCancel: (() -> Void)? let onUncancel: (() -> Void)? + let onMarkInProgress: (() -> Void)? + let onComplete: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -57,21 +59,39 @@ struct TaskCard: View { } if task.showCompletedButton { - Button(action: {}) { - HStack { - Image(systemName: "checkmark.circle.fill") - .resizable() - .frame(width: 20, height: 20) - Text("Complete Task") - .font(.title3.weight(.semibold)) + VStack(spacing: 8) { + if let onMarkInProgress = onMarkInProgress, task.status?.name != "in_progress" { + Button(action: onMarkInProgress) { + HStack { + Image(systemName: "play.circle.fill") + .resizable() + .frame(width: 18, height: 18) + Text("In Progress") + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.orange) + } + + if task.showCompletedButton, let onComplete = onComplete { + Button(action: onComplete) { + HStack { + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 18, height: 18) + Text("Complete") + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) } - .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) - .cornerRadius(12) } - HStack(spacing: 8) { + VStack(spacing: 8) { Button(action: onEdit) { Label("Edit", systemImage: "pencil") .font(.subheadline) @@ -139,7 +159,9 @@ struct TaskCard: View { ), onEdit: {}, onCancel: {}, - onUncancel: nil + onUncancel: nil, + onMarkInProgress: {}, + onComplete: {} ) } .padding() diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index df309d5..d662aa9 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -8,6 +8,8 @@ struct TasksSection: View { let onEditTask: (TaskDetail) -> Void let onCancelTask: (TaskDetail) -> Void let onUncancelTask: (TaskDetail) -> Void + let onMarkInProgress: (TaskDetail) -> Void + let onCompleteTask: (TaskDetail) -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -34,7 +36,9 @@ struct TasksSection: View { task: task, onEdit: { onEditTask(task) }, onCancel: { onCancelTask(task) }, - onUncancel: nil + onUncancel: nil, + onMarkInProgress: { onMarkInProgress(task) }, + onComplete: { onCompleteTask(task) } ) } @@ -64,7 +68,9 @@ struct TasksSection: View { task: task, onEdit: { onEditTask(task) }, onCancel: { onCancelTask(task) }, - onUncancel: nil + onUncancel: nil, + onMarkInProgress: nil, + onComplete: { onCompleteTask(task) } ) } } @@ -97,7 +103,9 @@ struct TasksSection: View { task: task, onEdit: { onEditTask(task) }, onCancel: nil, - onUncancel: nil + onUncancel: nil, + onMarkInProgress: nil, + onComplete: nil ) } } @@ -166,7 +174,9 @@ struct TasksSection: View { showDoneTasks: .constant(true), onEditTask: { _ in }, onCancelTask: { _ in }, - onUncancelTask: { _ in } + onUncancelTask: { _ in }, + onMarkInProgress: { _ in }, + onCompleteTask: { _ in } ) .padding() } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift new file mode 100644 index 0000000..6ba9842 --- /dev/null +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -0,0 +1,267 @@ +import SwiftUI +import ComposeApp + +struct AllTasksView: View { + @StateObject private var taskViewModel = TaskViewModel() + @State private var tasksResponse: AllTasksResponse? + @State private var isLoadingTasks = false + @State private var tasksError: String? + @State private var showAddTask = false + @State private var showEditTask = false + @State private var selectedTaskForEdit: TaskDetail? + @State private var showInProgressTasks = false + @State private var showDoneTasks = false + @State private var showCompleteTask = false + @State private var selectedTaskForComplete: TaskDetail? + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if isLoadingTasks { + ProgressView() + } else if let error = tasksError { + ErrorView(message: error) { + loadAllTasks() + } + } else if let tasksResponse = tasksResponse { + ScrollView { + VStack(spacing: 16) { + // Header Card + VStack(spacing: 12) { + Image(systemName: "checklist") + .font(.system(size: 48)) + .foregroundStyle(.blue.gradient) + + Text("All Tasks") + .font(.title) + .fontWeight(.bold) + + Text("Tasks across all your properties") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + .padding(.horizontal) + .padding(.top) + + // Tasks Section + AllTasksSectionView( + tasksResponse: tasksResponse, + showInProgressTasks: $showInProgressTasks, + showDoneTasks: $showDoneTasks, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: { task in + taskViewModel.cancelTask(id: task.id) { _ in + loadAllTasks() + } + }, + onUncancelTask: { task in + taskViewModel.uncancelTask(id: task.id) { _ in + loadAllTasks() + } + }, + onMarkInProgress: { task in + taskViewModel.markInProgress(id: task.id) { success in + if success { + loadAllTasks() + } + } + }, + onCompleteTask: { task in + selectedTaskForComplete = task + showCompleteTask = true + } + ) + .padding(.horizontal) + } + .padding(.bottom) + } + } + } + .navigationTitle("All Tasks") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showEditTask) { + if let task = selectedTaskForEdit { + EditTaskView(task: task, isPresented: $showEditTask) + } + } + .sheet(isPresented: $showCompleteTask) { + if let task = selectedTaskForComplete { + CompleteTaskView(task: task, isPresented: $showCompleteTask) { + loadAllTasks() + } + } + } + .onChange(of: showEditTask) { isShowing in + if !isShowing { + loadAllTasks() + } + } + .onAppear { + loadAllTasks() + } + } + + private func loadAllTasks() { + guard let token = TokenStorage.shared.getToken() else { return } + + isLoadingTasks = true + tasksError = nil + + let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) + taskApi.getTasks(token: token, days: 30) { result, error in + if let successResult = result as? ApiResultSuccess { + self.tasksResponse = successResult.data + self.isLoadingTasks = false + } else if let errorResult = result as? ApiResultError { + self.tasksError = errorResult.message + self.isLoadingTasks = false + } else if let error = error { + self.tasksError = error.localizedDescription + self.isLoadingTasks = false + } + } + } +} + +struct AllTasksSectionView: View { + let tasksResponse: AllTasksResponse + @Binding var showInProgressTasks: Bool + @Binding var showDoneTasks: Bool + let onEditTask: (TaskDetail) -> Void + let onCancelTask: (TaskDetail) -> Void + let onUncancelTask: (TaskDetail) -> Void + let onMarkInProgress: (TaskDetail) -> Void + let onCompleteTask: (TaskDetail) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Task summary pills + HStack(spacing: 8) { + TaskPill( + count: Int32(tasksResponse.summary.upcoming), + label: "Upcoming", + color: .blue + ) + + TaskPill( + count: Int32(tasksResponse.summary.inProgress), + label: "In Progress", + color: .orange + ) + + TaskPill( + count: Int32(tasksResponse.summary.done), + label: "Done", + color: .green + ) + } + .padding(.bottom, 4) + + // Upcoming tasks + if !tasksResponse.upcomingTasks.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Label("Upcoming (\(tasksResponse.upcomingTasks.count))", systemImage: "calendar") + .font(.headline) + .foregroundColor(.blue) + + ForEach(tasksResponse.upcomingTasks, id: \.id) { task in + TaskCard( + task: task, + onEdit: { onEditTask(task) }, + onCancel: { onCancelTask(task) }, + onUncancel: { onUncancelTask(task) }, + onMarkInProgress: { onMarkInProgress(task) }, + onComplete: { onCompleteTask(task) } + ) + } + } + } + + // In Progress section (collapsible) + if !tasksResponse.inProgressTasks.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("In Progress (\(tasksResponse.inProgressTasks.count))", systemImage: "play.circle") + .font(.headline) + .foregroundColor(.orange) + + Spacer() + + Image(systemName: showInProgressTasks ? "chevron.up" : "chevron.down") + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + showInProgressTasks.toggle() + } + } + + if showInProgressTasks { + ForEach(tasksResponse.inProgressTasks, id: \.id) { task in + TaskCard( + task: task, + onEdit: { onEditTask(task) }, + onCancel: { onCancelTask(task) }, + onUncancel: { onUncancelTask(task) }, + onMarkInProgress: nil, + onComplete: { onCompleteTask(task) } + ) + } + } + } + } + + // Done section (collapsible) + if !tasksResponse.doneTasks.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Done (\(tasksResponse.doneTasks.count))", systemImage: "checkmark.circle") + .font(.headline) + .foregroundColor(.green) + + Spacer() + + Image(systemName: showDoneTasks ? "chevron.up" : "chevron.down") + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + showDoneTasks.toggle() + } + } + + if showDoneTasks { + ForEach(tasksResponse.doneTasks, id: \.id) { task in + TaskCard( + task: task, + onEdit: { onEditTask(task) }, + onCancel: nil, + onUncancel: nil, + onMarkInProgress: nil, + onComplete: nil + ) + } + } + } + } + } + } +} + +#Preview { + NavigationView { + AllTasksView() + } +} diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift new file mode 100644 index 0000000..5023677 --- /dev/null +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -0,0 +1,300 @@ +import SwiftUI +import PhotosUI +import ComposeApp + +struct CompleteTaskView: View { + let task: TaskDetail + @Binding var isPresented: Bool + let onComplete: () -> Void + + @StateObject private var taskViewModel = TaskViewModel() + @State private var completedByName: String = "" + @State private var actualCost: String = "" + @State private var notes: String = "" + @State private var rating: Int = 3 + @State private var selectedItems: [PhotosPickerItem] = [] + @State private var selectedImages: [UIImage] = [] + @State private var isSubmitting: Bool = false + @State private var showError: Bool = false + @State private var errorMessage: String = "" + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 20) { + // Task Info Header + VStack(alignment: .leading, spacing: 8) { + Text(task.title) + .font(.title2) + .fontWeight(.bold) + + Text(task.category.name.capitalized) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Completed By + VStack(alignment: .leading, spacing: 8) { + Text("Completed By (Optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Enter name or leave blank", text: $completedByName) + .textFieldStyle(.roundedBorder) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Actual Cost + VStack(alignment: .leading, spacing: 8) { + Text("Actual Cost (Optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Text("$") + .foregroundColor(.secondary) + TextField("0.00", text: $actualCost) + .keyboardType(.decimalPad) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Notes + VStack(alignment: .leading, spacing: 8) { + Text("Notes (Optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + TextEditor(text: $notes) + .frame(minHeight: 100) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Rating + VStack(alignment: .leading, spacing: 12) { + Text("Rating") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 16) { + ForEach(1...5, id: \.self) { star in + Image(systemName: star <= rating ? "star.fill" : "star") + .font(.title2) + .foregroundColor(star <= rating ? .yellow : .gray) + .onTapGesture { + rating = star + } + } + } + + Text("\(rating) out of 5") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Image Picker + VStack(alignment: .leading, spacing: 12) { + Text("Add Images (up to 5)") + .font(.subheadline) + .foregroundColor(.secondary) + + PhotosPicker( + selection: $selectedItems, + maxSelectionCount: 5, + matching: .images + ) { + HStack { + Image(systemName: "photo.on.rectangle.angled") + Text("Select Images") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(8) + } + .onChange(of: selectedItems) { newItems in + Task { + selectedImages = [] + for item in newItems { + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + selectedImages.append(image) + } + } + } + } + + // Display selected images + if !selectedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(selectedImages.indices, id: \.self) { index in + ZStack(alignment: .topTrailing) { + Image(uiImage: selectedImages[index]) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Button(action: { + selectedImages.remove(at: index) + selectedItems.remove(at: index) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.white) + .background(Circle().fill(Color.black.opacity(0.6))) + } + .padding(4) + } + } + } + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Complete Button + Button(action: handleComplete) { + HStack { + if isSubmitting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "checkmark.circle.fill") + Text("Complete Task") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(isSubmitting ? Color.gray : Color.green) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(isSubmitting) + .padding() + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Complete Task") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + isPresented = false + } + } + } + .alert("Error", isPresented: $showError) { + Button("OK") { + showError = false + } + } message: { + Text(errorMessage) + } + } + } + + private func handleComplete() { + isSubmitting = true + + guard let token = TokenStorage.shared.getToken() else { + errorMessage = "Not authenticated" + showError = true + isSubmitting = false + return + } + + // Get current date in ISO format + let dateFormatter = ISO8601DateFormatter() + let currentDate = dateFormatter.string(from: Date()) + + // Create request + let request = TaskCompletionCreateRequest( + task: task.id, + completedByUser: nil, + completedByName: completedByName.isEmpty ? nil : completedByName, + completionDate: currentDate, + actualCost: actualCost.isEmpty ? nil : actualCost, + notes: notes.isEmpty ? nil : notes, + rating: KotlinInt(int: Int32(rating)) + ) + + let completionApi = TaskCompletionApi(client: ApiClient_iosKt.createHttpClient()) + + // If there are images, upload with images + if !selectedImages.isEmpty { + let imageDataArray = selectedImages.compactMap { $0.jpegData(compressionQuality: 0.8) } + let imageByteArrays = imageDataArray.map { KotlinByteArray(data: $0) } + let fileNames = (0..?, error: Error?) { + if result is ApiResultSuccess { + isSubmitting = false + isPresented = false + onComplete() + } else if let errorResult = result as? ApiResultError { + errorMessage = errorResult.message + showError = true + isSubmitting = false + } else if let error = error { + errorMessage = error.localizedDescription + showError = true + isSubmitting = false + } + } +} + +// Helper extension to convert Data to KotlinByteArray +extension KotlinByteArray { + convenience init(data: Data) { + let array = [UInt8](data) + self.init(size: Int32(array.count)) + for (index, byte) in array.enumerated() { + self.set(index: Int32(index), value: Int8(bitPattern: byte)) + } + } +} diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index a96b02b..feba848 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -11,6 +11,7 @@ class TaskViewModel: ObservableObject { @Published var taskUpdated: Bool = false @Published var taskCancelled: Bool = false @Published var taskUncancelled: Bool = false + @Published var taskMarkedInProgress: Bool = false // MARK: - Private Properties private let taskApi: TaskApi @@ -19,8 +20,7 @@ class TaskViewModel: ObservableObject { // MARK: - Initialization init() { self.taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) - self.tokenStorage = TokenStorage() - self.tokenStorage.initialize(manager: TokenManager.init()) + self.tokenStorage = TokenStorage.shared } // MARK: - Public Methods @@ -140,11 +140,81 @@ class TaskViewModel: ObservableObject { errorMessage = nil } + func markInProgress(id: Int32, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isLoading = true + errorMessage = nil + taskMarkedInProgress = false + + taskApi.markInProgress(token: token, id: id) { result, error in + if result is ApiResultSuccess { + self.isLoading = false + self.taskMarkedInProgress = true + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } + } + + func completeTask(taskId: Int32, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isLoading = true + errorMessage = nil + + // Get current date in ISO format + let dateFormatter = ISO8601DateFormatter() + let currentDate = dateFormatter.string(from: Date()) + + let request = TaskCompletionCreateRequest( + task: taskId, + completedByUser: nil, + completedByName: nil, + completionDate: currentDate, + actualCost: nil, + notes: nil, + rating: nil + ) + + let completionApi = TaskCompletionApi(client: ApiClient_iosKt.createHttpClient()) + completionApi.createCompletion(token: token, request: request) { result, error in + if result is ApiResultSuccess { + self.isLoading = false + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } + } + func resetState() { taskCreated = false taskUpdated = false taskCancelled = false taskUncancelled = false + taskMarkedInProgress = false errorMessage = nil } } diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift index 930c949..c269c86 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -17,8 +17,7 @@ class VerifyEmailViewModel: ObservableObject { // MARK: - Initialization init() { self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) - self.tokenStorage = TokenStorage() - self.tokenStorage.initialize(manager: TokenManager.init()) + self.tokenStorage = TokenStorage.shared } // MARK: - Public Methods diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 5d7d242..f53af62 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,7 +1,13 @@ import SwiftUI +import ComposeApp @main struct iOSApp: App { + init() { + // Initialize TokenStorage once at app startup + TokenStorage.shared.initialize(manager: TokenManager()) + } + var body: some Scene { WindowGroup { LoginView()