wip
This commit is contained in:
@@ -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<LoginRoute> { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
@@ -151,7 +152,7 @@ fun App() {
|
||||
VerifyEmailScreen(
|
||||
onVerifySuccess = {
|
||||
isVerified = true
|
||||
navController.navigate(ResidencesRoute) {
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo<VerifyEmailRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
@@ -168,10 +169,79 @@ fun App() {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
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<HomeRoute> {
|
||||
HomeScreen(
|
||||
onNavigateToResidences = {
|
||||
navController.navigate(ResidencesRoute)
|
||||
navController.navigate(MainRoute)
|
||||
},
|
||||
onNavigateToTasks = {
|
||||
navController.navigate(TasksRoute)
|
||||
|
||||
@@ -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<TaskDetail>,
|
||||
@SerialName("in_progress_tasks") val inProgressTasks: List<TaskDetail>,
|
||||
@SerialName("done_tasks") val doneTasks: List<TaskDetail>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskCancelResponse(
|
||||
val message: String,
|
||||
|
||||
@@ -73,3 +73,15 @@ object TasksRoute
|
||||
|
||||
@Serializable
|
||||
object ProfileRoute
|
||||
|
||||
@Serializable
|
||||
object MainRoute
|
||||
|
||||
@Serializable
|
||||
object MainTabResidencesRoute
|
||||
|
||||
@Serializable
|
||||
object MainTabTasksRoute
|
||||
|
||||
@Serializable
|
||||
object MainTabProfileRoute
|
||||
|
||||
@@ -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<List<CustomTask>> {
|
||||
suspend fun getTasks(
|
||||
token: String,
|
||||
days: Int = 30
|
||||
): ApiResult<AllTasksResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/") {
|
||||
header("Authorization", "Token $token")
|
||||
parameter("days", days)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val data: PaginatedResponse<CustomTask> = 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<TaskCancelResponse> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TaskDetail?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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<MainTabResidencesRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ResidencesScreen(
|
||||
onResidenceClick = onResidenceClick,
|
||||
onAddResidence = onAddResidence,
|
||||
onLogout = onLogout,
|
||||
onNavigateToProfile = {
|
||||
selectedTab = 2
|
||||
navController.navigate(MainTabProfileRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainTabTasksRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AllTasksScreen(
|
||||
onNavigateToEditTask = onNavigateToEditTask
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainTabProfileRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ProfileScreen(
|
||||
onNavigateBack = {
|
||||
selectedTab = 0
|
||||
navController.navigate(MainTabResidencesRoute)
|
||||
},
|
||||
onLogout = onLogout
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<com.mycrib.shared.models.TaskDetail?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<CustomTask>>>(ApiResult.Loading)
|
||||
val tasksState: StateFlow<ApiResult<List<CustomTask>>> = _tasksState
|
||||
private val _tasksState = MutableStateFlow<ApiResult<AllTasksResponse>>(ApiResult.Loading)
|
||||
val tasksState: StateFlow<ApiResult<AllTasksResponse>> = _tasksState
|
||||
|
||||
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
||||
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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<AuthResponse>) {
|
||||
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<User> {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -121,7 +121,7 @@ struct RegisterView: View {
|
||||
},
|
||||
onLogout: {
|
||||
// Logout and return to login screen
|
||||
TokenManager().clearToken()
|
||||
TokenStorage.shared.clearToken()
|
||||
LookupsManager.shared.clear()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
267
iosApp/iosApp/Task/AllTasksView.swift
Normal file
267
iosApp/iosApp/Task/AllTasksView.swift
Normal file
@@ -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<AllTasksResponse> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
300
iosApp/iosApp/Task/CompleteTaskView.swift
Normal file
300
iosApp/iosApp/Task/CompleteTaskView.swift
Normal file
@@ -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..<imageDataArray.count).map { "image_\($0).jpg" }
|
||||
|
||||
completionApi.createCompletionWithImages(
|
||||
token: token,
|
||||
request: request,
|
||||
images: imageByteArrays,
|
||||
imageFileNames: fileNames
|
||||
) { result, error in
|
||||
handleCompletionResult(result: result, error: error)
|
||||
}
|
||||
} else {
|
||||
// Upload without images
|
||||
completionApi.createCompletion(token: token, request: request) { result, error in
|
||||
handleCompletionResult(result: result, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCompletionResult(result: ApiResult<TaskCompletion>?, error: Error?) {
|
||||
if result is ApiResultSuccess<TaskCompletion> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TaskCancelResponse> {
|
||||
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<TaskCompletion> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user