wip
This commit is contained in:
@@ -53,6 +53,17 @@ kotlin {
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
jsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
wasmJsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
@@ -74,10 +85,6 @@ kotlin {
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
package com.mycrib.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
actual fun getLocalhostAddress(): String = "10.0.2.2"
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
package com.mycrib.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.darwin.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
actual fun getLocalhostAddress(): String = "127.0.0.1"
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(Darwin) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
|
||||
engine {
|
||||
configureRequest {
|
||||
setAllowsCellularAccess(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
package com.mycrib.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.js.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
actual fun getLocalhostAddress(): String = "127.0.0.1"
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(Js) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
package com.mycrib.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
actual fun getLocalhostAddress(): String = "127.0.0.1"
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
package com.mycrib.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.js.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
actual fun getLocalhostAddress(): String = "127.0.0.1"
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(Js) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,7 @@ org.gradle.caching=true
|
||||
|
||||
#Android
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
android.useAndroidX=true
|
||||
|
||||
|
||||
kotlin.native.binary.objcDisposeOnMain=false
|
||||
@@ -40,6 +40,8 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx
|
||||
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user