This commit is contained in:
Trey t
2025-11-06 09:25:21 -06:00
parent e272e45689
commit e24d1d8559
29 changed files with 1806 additions and 103 deletions

View File

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

View File

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

View File

@@ -73,3 +73,15 @@ object TasksRoute
@Serializable
object ProfileRoute
@Serializable
object MainRoute
@Serializable
object MainTabResidencesRoute
@Serializable
object MainTabTasksRoute
@Serializable
object MainTabProfileRoute

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,7 @@ struct RegisterView: View {
},
onLogout: {
// Logout and return to login screen
TokenManager().clearToken()
TokenStorage.shared.clearToken()
LookupsManager.shared.clear()
dismiss()
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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