This commit is contained in:
Trey t
2025-11-04 15:41:03 -06:00
parent f2ade0a1e2
commit 219eaa69ee
17 changed files with 637 additions and 92 deletions

View File

@@ -106,6 +106,14 @@ fun App() {
},
onAddResidence = {
navController.navigate(AddResidenceRoute)
},
onLogout = {
// Clear token on logout
com.mycrib.storage.TokenStorage.clearToken()
isLoggedIn = false
navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true }
}
}
)
}

View File

@@ -20,6 +20,7 @@ data class Task(
val notes: String?,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("show_completed_button") val showCompletedButton: Boolean = false,
@SerialName("days_until_due") val daysUntilDue: Int? = null,
@SerialName("is_overdue") val isOverdue: Boolean? = null,
@SerialName("last_completion") val lastCompletion: LastCompletion? = null
@@ -39,11 +40,12 @@ data class TaskCreateRequest(
val title: String,
val description: String? = null,
val category: String,
val priority: String,
val frequency: String = "once",
@SerialName("interval_days") val intervalDays: Int? = null,
val priority: String = "medium",
val status: String = "pending",
@SerialName("due_date") val dueDate: String,
@SerialName("estimated_cost") val estimatedCost: String? = null,
val notes: String? = null
@SerialName("estimated_cost") val estimatedCost: String? = null
)
@Serializable
@@ -65,6 +67,7 @@ data class TaskDetail(
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("next_scheduled_date") val nextScheduledDate: String? = null,
@SerialName("show_completed_button") val showCompletedButton: Boolean = false,
val completions: List<TaskCompletion>
)

View File

@@ -7,24 +7,12 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
expect fun getLocalhostAddress(): String
expect fun createHttpClient(): HttpClient
object ApiClient {
private val BASE_URL = "http://${getLocalhostAddress()}:8000/api"
val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
})
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
val httpClient = createHttpClient()
fun getBaseUrl() = BASE_URL
}

View File

@@ -0,0 +1,305 @@
package com.mycrib.android.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.mycrib.shared.models.TaskCreateRequest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddNewTaskDialog(
residenceId: Int,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit
) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var category by remember { mutableStateOf("") }
var frequency by remember { mutableStateOf("once") }
var intervalDays by remember { mutableStateOf("") }
var priority by remember { mutableStateOf("medium") }
var dueDate by remember { mutableStateOf("") }
var estimatedCost by remember { mutableStateOf("") }
var showFrequencyDropdown by remember { mutableStateOf(false) }
var showPriorityDropdown by remember { mutableStateOf(false) }
var showCategoryDropdown by remember { mutableStateOf(false) }
var titleError by remember { mutableStateOf(false) }
var categoryError by remember { mutableStateOf(false) }
var dueDateError by remember { mutableStateOf(false) }
val frequencies = listOf(
"once" to "One Time",
"daily" to "Daily",
"weekly" to "Weekly",
"biweekly" to "Bi-Weekly",
"monthly" to "Monthly",
"quarterly" to "Quarterly",
"semiannually" to "Semi-Annually",
"annually" to "Annually"
)
val priorities = listOf(
"low" to "Low",
"medium" to "Medium",
"high" to "High",
"urgent" to "Urgent"
)
val categories = listOf(
"Plumbing",
"Electrical",
"HVAC",
"Landscaping",
"Painting",
"Roofing",
"Flooring",
"Appliances",
"General Maintenance",
"Cleaning",
"Inspection",
"Other"
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Task") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Title
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
},
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError,
supportingText = if (titleError) {
{ Text("Title is required") }
} else null,
singleLine = true
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
)
// Category
ExposedDropdownMenuBox(
expanded = showCategoryDropdown,
onExpandedChange = { showCategoryDropdown = it }
) {
OutlinedTextField(
value = category,
onValueChange = {
category = it
categoryError = false
},
label = { Text("Category *") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = categoryError,
supportingText = if (categoryError) {
{ Text("Category is required") }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
readOnly = false
)
ExposedDropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false }
) {
categories.forEach { cat ->
DropdownMenuItem(
text = { Text(cat) },
onClick = {
category = cat
categoryError = false
showCategoryDropdown = false
}
)
}
}
}
// Frequency
ExposedDropdownMenuBox(
expanded = showFrequencyDropdown,
onExpandedChange = { showFrequencyDropdown = it }
) {
OutlinedTextField(
value = frequencies.find { it.first == frequency }?.second ?: "One Time",
onValueChange = { },
label = { Text("Frequency") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) }
)
ExposedDropdownMenu(
expanded = showFrequencyDropdown,
onDismissRequest = { showFrequencyDropdown = false }
) {
frequencies.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
frequency = key
showFrequencyDropdown = false
// Clear interval days if frequency is "once"
if (key == "once") {
intervalDays = ""
}
}
)
}
}
}
// Interval Days (only for recurring tasks)
if (frequency != "once") {
OutlinedTextField(
value = intervalDays,
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
label = { Text("Interval Days (optional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text("Override default frequency interval") },
singleLine = true
)
}
// Due Date
OutlinedTextField(
value = dueDate,
onValueChange = {
dueDate = it
dueDateError = false
},
label = { Text("Due Date (YYYY-MM-DD) *") },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError,
supportingText = if (dueDateError) {
{ Text("Due date is required (format: YYYY-MM-DD)") }
} else {
{ Text("Format: YYYY-MM-DD") }
},
singleLine = true
)
// Priority
ExposedDropdownMenuBox(
expanded = showPriorityDropdown,
onExpandedChange = { showPriorityDropdown = it }
) {
OutlinedTextField(
value = priorities.find { it.first == priority }?.second ?: "Medium",
onValueChange = { },
label = { Text("Priority") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) }
)
ExposedDropdownMenu(
expanded = showPriorityDropdown,
onDismissRequest = { showPriorityDropdown = false }
) {
priorities.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
priority = key
showPriorityDropdown = false
}
)
}
}
}
// Estimated Cost
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") },
singleLine = true
)
}
},
confirmButton = {
Button(
onClick = {
// Validation
var hasError = false
if (title.isBlank()) {
titleError = true
hasError = true
}
if (category.isBlank()) {
categoryError = true
hasError = true
}
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
dueDateError = true
hasError = true
}
if (!hasError) {
onCreate(
TaskCreateRequest(
residence = residenceId,
title = title,
description = description.ifBlank { null },
category = category,
frequency = frequency,
intervalDays = intervalDays.toIntOrNull(),
priority = priority,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)
)
}
}
) {
Text("Create Task")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
// Helper function to validate date format
private fun isValidDateFormat(date: String): Boolean {
val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
return datePattern.matches(date)
}

View File

@@ -9,12 +9,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.viewmodel.AuthViewModel
import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.shared.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
onRegisterSuccess: () -> Unit,
onNavigateBack: () -> Unit
onNavigateBack: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
@@ -23,6 +28,18 @@ fun RegisterScreen(
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val createState by viewModel.registerState.collectAsState()
LaunchedEffect(createState) {
when (createState) {
is ApiResult.Success -> {
viewModel.resetRegisterState()
onRegisterSuccess()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
@@ -102,8 +119,7 @@ fun RegisterScreen(
else -> {
isLoading = true
errorMessage = ""
// TODO: Call API
onRegisterSuccess()
viewModel.register(username, email, password)
}
}
},

View File

@@ -12,9 +12,11 @@ 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.AddNewTaskDialog
import com.mycrib.android.ui.components.CompleteTaskDialog
import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.android.viewmodel.TaskCompletionViewModel
import com.mycrib.android.viewmodel.TaskViewModel
import com.mycrib.shared.models.Residence
import com.mycrib.shared.models.TaskDetail
import com.mycrib.shared.network.ApiResult
@@ -25,7 +27,8 @@ fun ResidenceDetailScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
@@ -34,6 +37,8 @@ fun ResidenceDetailScreen(
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) }
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
@@ -70,6 +75,18 @@ fun ResidenceDetailScreen(
)
}
if (showNewTaskDialog) {
AddNewTaskDialog(
residenceId,
onDismiss = {
showNewTaskDialog = false
}, onCreate = { request ->
showNewTaskDialog = false
val newTask = taskViewModel.createNewTask(request)
residenceViewModel.loadResidenceTasks(residenceId)
})
}
Scaffold(
topBar = {
TopAppBar(
@@ -238,6 +255,19 @@ fun ResidenceDetailScreen(
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Button(
onClick = { showNewTaskDialog = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Add New Task")
}
}
when (tasksState) {
@@ -354,90 +384,110 @@ fun TaskCard(
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Due: ${task.dueDate}",
style = MaterialTheme.typography.bodySmall
)
task.estimatedCost?.let {
task.nextScheduledDate?.let {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Est. Cost: $$it",
text = "Next Due Date: $it",
style = MaterialTheme.typography.bodySmall
)
task.estimatedCost?.let {
Text(
text = "Est. Cost: $$it",
style = MaterialTheme.typography.bodySmall
)
}
}
} ?: run {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Due: ${task.dueDate}",
style = MaterialTheme.typography.bodySmall
)
task.estimatedCost?.let {
Text(
text = "Est. Cost: $$it",
style = MaterialTheme.typography.bodySmall
)
}
}
}
if (!task.frequency.equals("once")) {
// Show completions
if (task.completions.isNotEmpty()) {
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Completions (${task.completions.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
task.completions.forEach { completion ->
Spacer(modifier = Modifier.height(8.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
// Show completions
if (task.completions.isNotEmpty()) {
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Completions (${task.completions.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
task.completions.forEach { completion ->
Spacer(modifier = Modifier.height(8.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = completion.completionDate.split("T")[0],
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
completion.rating?.let { rating ->
Text(
text = completion.completionDate.split("T")[0],
text = "$rating",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.tertiary
)
completion.rating?.let { rating ->
Text(
text = "$rating",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.tertiary
)
}
}
}
completion.completedByName?.let {
Text(
text = "By: $it",
style = MaterialTheme.typography.bodySmall
)
}
completion.completedByName?.let {
Text(
text = "By: $it",
style = MaterialTheme.typography.bodySmall
)
}
completion.actualCost?.let {
Text(
text = "Cost: $$it",
style = MaterialTheme.typography.bodySmall
)
}
completion.actualCost?.let {
Text(
text = "Cost: $$it",
style = MaterialTheme.typography.bodySmall
)
}
completion.notes?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
completion.notes?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// Complete task button
// Show complete task button based on API logic
if (task.showCompletedButton) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onCompleteClick,

View File

@@ -11,14 +11,17 @@ 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.viewmodel.AuthViewModel
import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.AuthApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidencesScreen(
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onLogout: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val myResidencesState by viewModel.myResidencesState.collectAsState()
@@ -35,6 +38,9 @@ fun ResidencesScreen(
IconButton(onClick = onAddResidence) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Logout")
}
}
)
}

View File

@@ -2,8 +2,10 @@ package com.mycrib.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mycrib.shared.models.AuthResponse
import com.mycrib.shared.models.LoginRequest
import com.mycrib.shared.models.RegisterRequest
import com.mycrib.shared.models.Residence
import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.AuthApi
import com.mycrib.storage.TokenStorage
@@ -17,6 +19,9 @@ class AuthViewModel : ViewModel() {
private val _loginState = MutableStateFlow<ApiResult<String>>(ApiResult.Loading)
val loginState: StateFlow<ApiResult<String>> = _loginState
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Loading)
val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
@@ -35,7 +40,7 @@ class AuthViewModel : ViewModel() {
fun register(username: String, email: String, password: String) {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
_registerState.value = ApiResult.Loading
val result = authApi.register(
RegisterRequest(
username = username,
@@ -43,11 +48,11 @@ class AuthViewModel : ViewModel() {
password = password
)
)
_loginState.value = when (result) {
_registerState.value = when (result) {
is ApiResult.Success -> {
// Store token for future API calls
TokenStorage.saveToken(result.data.token)
ApiResult.Success(result.data.token)
ApiResult.Success(result.data)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
@@ -55,6 +60,10 @@ class AuthViewModel : ViewModel() {
}
}
fun resetRegisterState() {
_registerState.value = ApiResult.Loading
}
fun logout() {
viewModelScope.launch {
val token = TokenStorage.getToken()

View File

@@ -2,7 +2,10 @@ package com.mycrib.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mycrib.shared.models.ResidenceCreateRequest
import com.mycrib.shared.models.Task
import com.mycrib.shared.models.TaskCreateRequest
import com.mycrib.shared.models.TaskDetail
import com.mycrib.shared.models.TasksByResidenceResponse
import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.TaskApi
@@ -20,6 +23,9 @@ class TaskViewModel : ViewModel() {
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
private val _taskAddNewTaskState = MutableStateFlow<ApiResult<Task>>(ApiResult.Loading)
val taskAddNewTaskState: StateFlow<ApiResult<Task>> = _taskAddNewTaskState
fun loadTasks() {
viewModelScope.launch {
_tasksState.value = ApiResult.Loading
@@ -43,4 +49,20 @@ class TaskViewModel : ViewModel() {
}
}
}
fun createNewTask(request: TaskCreateRequest) {
viewModelScope.launch {
_taskAddNewTaskState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_taskAddNewTaskState.value = taskApi.createTask(token, request)
} else {
_taskAddNewTaskState.value = ApiResult.Error("Not authenticated", 401)
}
}
}
fun resetCreateTaskState() {
_taskAddNewTaskState.value = ApiResult.Loading
}
}