This commit is contained in:
Trey t
2025-11-07 12:21:48 -06:00
parent 66fe773398
commit 1b777049a8
27 changed files with 2003 additions and 718 deletions

255
ENVIRONMENT_SETUP.md Normal file
View File

@@ -0,0 +1,255 @@
# Environment Configuration Guide
This guide explains how to easily switch between local development and the dev server when developing the MyCrib iOS and Android apps.
## Quick Start
**To switch environments, change ONE line in `ApiConfig.kt`:**
```kotlin
// File: composeApp/src/commonMain/kotlin/com/mycrib/shared/network/ApiConfig.kt
object ApiConfig {
// ⚠️ CHANGE THIS LINE ⚠️
val CURRENT_ENV = Environment.LOCAL // or Environment.DEV
}
```
## Environment Options
### 1. Local Development (`Environment.LOCAL`)
**Use this when:**
- Running the Django API on your local machine
- Debugging API changes
- Working offline
**Connects to:**
- **Android**: `http://10.0.2.2:8000/api` (Android emulator localhost alias)
- **iOS**: `http://127.0.0.1:8000/api` (iOS simulator localhost)
**Setup:**
```kotlin
val CURRENT_ENV = Environment.LOCAL
```
**Requirements:**
- Django API running on `http://localhost:8000`
- Use `./dev.sh` to start the API with auto-reload
### 2. Dev Server (`Environment.DEV`)
**Use this when:**
- Testing against the deployed server
- You don't have the API running locally
- Testing with real data
**Connects to:**
- **Both platforms**: `https://mycrib.treytartt.com/api`
**Setup:**
```kotlin
val CURRENT_ENV = Environment.DEV
```
**Requirements:**
- Internet connection
- Dev server must be running and accessible
## Step-by-Step Instructions
### Switching to Local Development
1. **Start your local API:**
```bash
cd myCribAPI
./dev.sh
```
2. **Update `ApiConfig.kt`:**
```kotlin
val CURRENT_ENV = Environment.LOCAL
```
3. **Rebuild the app:**
- **Android**: Sync Gradle and run
- **iOS**: Clean build folder (⇧⌘K) and run
4. **Verify in logs:**
```
🌐 API Client initialized
📍 Environment: Local (10.0.2.2:8000)
🔗 Base URL: http://10.0.2.2:8000/api
```
### Switching to Dev Server
1. **Update `ApiConfig.kt`:**
```kotlin
val CURRENT_ENV = Environment.DEV
```
2. **Rebuild the app:**
- **Android**: Sync Gradle and run
- **iOS**: Clean build folder (⇧⌘K) and run
3. **Verify in logs:**
```
🌐 API Client initialized
📍 Environment: Dev Server (mycrib.treytartt.com)
🔗 Base URL: https://mycrib.treytartt.com/api
```
## Platform-Specific Localhost Addresses
The localhost addresses are automatically determined by platform:
| Platform | Localhost Address | Reason |
|----------|-------------------|--------|
| Android Emulator | `10.0.2.2` | Special alias for host machine's localhost |
| iOS Simulator | `127.0.0.1` | Standard localhost (simulator shares network with host) |
| Android Device | Your machine's IP | Must manually set in `ApiClient.android.kt` |
| iOS Device | Your machine's IP | Must manually set in `ApiClient.ios.kt` |
### Testing on Physical Devices
If you need to test on a physical device with local API:
1. **Find your machine's IP address:**
```bash
# macOS/Linux
ifconfig | grep "inet "
# Look for something like: 192.168.1.xxx
```
2. **Update platform-specific file:**
**Android** (`ApiClient.android.kt`):
```kotlin
actual fun getLocalhostAddress(): String = "192.168.1.xxx"
```
**iOS** (`ApiClient.ios.kt`):
```kotlin
actual fun getLocalhostAddress(): String = "192.168.1.xxx"
```
3. **Ensure your device is on the same WiFi network as your machine**
4. **Update Django's `ALLOWED_HOSTS`:**
```python
# myCribAPI/myCrib/settings.py
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '192.168.1.xxx']
```
## File Structure
```
MyCribKMM/composeApp/src/
├── commonMain/kotlin/com/mycrib/shared/network/
│ ├── ApiConfig.kt # ⭐ TOGGLE ENVIRONMENT HERE
│ └── ApiClient.kt # Uses ApiConfig
├── androidMain/kotlin/com/mycrib/shared/network/
│ └── ApiClient.android.kt # Android localhost: 10.0.2.2
└── iosMain/kotlin/com/mycrib/shared/network/
└── ApiClient.ios.kt # iOS localhost: 127.0.0.1
```
## Troubleshooting
### Android: "Unable to connect to server"
**Problem:** Android can't reach localhost
**Solutions:**
1. Use `10.0.2.2` instead of `localhost` or `127.0.0.1`
2. Make sure API is running on `0.0.0.0:8000`, not just `127.0.0.1:8000`
3. Check that `CURRENT_ENV = Environment.LOCAL`
### iOS: "Connection refused"
**Problem:** iOS simulator can't connect
**Solutions:**
1. Use `127.0.0.1` for iOS simulator
2. Make sure Django is running
3. Try accessing `http://127.0.0.1:8000/api` in Safari on your Mac
4. Check firewall settings
### Dev Server: SSL/Certificate errors
**Problem:** HTTPS connection issues
**Solutions:**
1. Verify server is accessible: `curl https://mycrib.treytartt.com/api`
2. Check that SSL certificate is valid
3. Make sure you're using `https://` not `http://`
### Changes not taking effect
**Problem:** Environment change not working
**Solutions:**
1. **Clean and rebuild:**
- Android: Build → Clean Project, then rebuild
- iOS: Product → Clean Build Folder (⇧⌘K)
2. **Invalidate caches:**
- Android Studio: File → Invalidate Caches
3. **Check logs for current environment:**
- Look for `🌐 API Client initialized` log message
## Best Practices
1. **Commit with LOCAL:** Always commit code with `Environment.LOCAL` so teammates use their local API by default
2. **Document API changes:** If you change the API, update both local and dev server
3. **Test both environments:** Before deploying, test with both LOCAL and DEV
4. **Use dev server for demos:** Switch to DEV when showing the app to others
5. **Keep localhost addresses:** Don't commit IP addresses, use the platform-specific aliases
## Quick Reference
| Action | Command/File |
|--------|--------------|
| Toggle environment | Edit `ApiConfig.kt` → `CURRENT_ENV` |
| Start local API | `cd myCribAPI && ./dev.sh` |
| Android localhost | `10.0.2.2:8000` |
| iOS localhost | `127.0.0.1:8000` |
| Dev server | `https://mycrib.treytartt.com` |
| View current env | Check app logs for `🌐` emoji |
## Example Workflow
**Morning: Start work**
```kotlin
// ApiConfig.kt
val CURRENT_ENV = Environment.LOCAL // ✅ Use local API
```
```bash
cd myCribAPI
./dev.sh # Start local server
# Work on features...
```
**Testing: Check remote data**
```kotlin
// ApiConfig.kt
val CURRENT_ENV = Environment.DEV // ✅ Use dev server
# Rebuild app and test
```
**Before commit**
```kotlin
// ApiConfig.kt
val CURRENT_ENV = Environment.LOCAL // ✅ Reset to LOCAL
git add .
git commit -m "Add new feature"
```
---
**Need help?** Check the logs when the app starts - they'll tell you exactly which environment and URL is being used!

64
QUICK_START.md Normal file
View File

@@ -0,0 +1,64 @@
# MyCrib KMM - Quick Start
## 🚀 Switch API Environment
**File:** `composeApp/src/commonMain/kotlin/com/mycrib/shared/network/ApiConfig.kt`
```kotlin
object ApiConfig {
val CURRENT_ENV = Environment.LOCAL // ⬅️ CHANGE THIS
}
```
### Options:
- **`Environment.LOCAL`** → Your local API (localhost)
- **`Environment.DEV`** → Dev server (https://mycrib.treytartt.com)
### After Changing:
1. **Android**: Sync Gradle and run
2. **iOS**: Clean Build Folder (⇧⌘K) and run
### Verify in Logs:
```
🌐 API Client initialized
📍 Environment: Local (10.0.2.2:8000)
🔗 Base URL: http://10.0.2.2:8000/api
```
---
## 📱 Run the Apps
### Android
```bash
cd MyCribKMM
./gradlew :composeApp:installDebug
```
### iOS
```bash
cd MyCribKMM/iosApp
open iosApp.xcodeproj
# Run in Xcode
```
---
## 🔧 Start Local API
```bash
cd myCribAPI
./dev.sh # Auto-reload on code changes
```
---
## 📚 Full Guides
- **Environment Setup**: `ENVIRONMENT_SETUP.md`
- **Workspace Overview**: `../WORKSPACE_OVERVIEW.md`
- **API Deployment**: `../myCribAPI/DOKKU_SETUP_GUIDE.md`
---
**That's it!** Change one line to toggle between local and remote development. ✨

View File

@@ -3,11 +3,13 @@ package com.example.mycrib
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -32,9 +34,18 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.mycrib.android.ui.screens.MainScreen
import com.mycrib.android.ui.screens.ProfileScreen
import com.mycrib.android.ui.theme.MyCribTheme
import com.mycrib.navigation.*
import com.mycrib.repository.LookupsRepository
import com.mycrib.shared.models.Residence
import com.mycrib.shared.models.TaskCategory
import com.mycrib.shared.models.TaskDetail
import com.mycrib.shared.models.TaskFrequency
import com.mycrib.shared.models.TaskPriority
import com.mycrib.shared.models.TaskStatus
import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.AuthApi
import com.mycrib.storage.TokenStorage
import mycrib.composeapp.generated.resources.Res
@@ -55,12 +66,12 @@ fun App() {
if (hasToken) {
// Fetch current user to check verification status
val authApi = com.mycrib.shared.network.AuthApi()
val authApi = AuthApi()
val token = TokenStorage.getToken()
if (token != null) {
when (val result = authApi.getCurrentUser(token)) {
is com.mycrib.shared.network.ApiResult.Success -> {
is ApiResult.Success -> {
isVerified = result.data.verified
LookupsRepository.initialize()
}
@@ -76,33 +87,34 @@ fun App() {
isCheckingAuth = false
}
if (isCheckingAuth) {
// Show loading screen while checking auth
MyCribTheme {
if (isCheckingAuth) {
// Show loading screen while checking auth
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
return@MyCribTheme
}
val startDestination = when {
!isLoggedIn -> LoginRoute
!isVerified -> VerifyEmailRoute
else -> MainRoute
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.CircularProgressIndicator()
}
}
return
}
val startDestination = when {
!isLoggedIn -> LoginRoute
!isVerified -> VerifyEmailRoute
else -> MainRoute
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NavHost(
NavHost(
navController = navController,
startDestination = startDestination
) {
@@ -187,6 +199,11 @@ fun App() {
onAddResidence = {
navController.navigate(AddResidenceRoute)
},
onAddTask = {
// Tasks are added from within a residence
// Navigate to first residence or show message if no residences exist
// For now, this will be handled by the UI showing "add a property first"
},
onNavigateToEditResidence = { residence ->
navController.navigate(
EditResidenceRoute(
@@ -399,16 +416,20 @@ fun App() {
composable<EditTaskRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditTaskRoute>()
EditTaskScreen(
task = com.mycrib.shared.models.TaskDetail(
task = TaskDetail(
id = route.taskId,
residence = route.residenceId,
title = route.title,
description = route.description,
category = com.mycrib.shared.models.TaskCategory(route.categoryId, route.categoryName),
frequency = com.mycrib.shared.models.TaskFrequency(route.frequencyId, route.frequencyName, ""),
priority = com.mycrib.shared.models.TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
category = TaskCategory(route.categoryId, route.categoryName),
frequency = TaskFrequency(
route.frequencyId, route.frequencyName, "",
daySpan = 0,
notifyDays = 0
),
priority = TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
status = route.statusId?.let {
com.mycrib.shared.models.TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "")
TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "")
},
dueDate = route.dueDate,
estimatedCost = route.estimatedCost,
@@ -426,7 +447,7 @@ fun App() {
}
composable<ProfileRoute> {
com.mycrib.android.ui.screens.ProfileScreen(
ProfileScreen(
onNavigateBack = {
navController.popBackStack()
},
@@ -443,6 +464,7 @@ fun App() {
)
}
}
}
}

View File

@@ -13,11 +13,12 @@ data class CustomTask (
val description: String? = null,
val category: String,
val priority: String,
val status: String,
val status: String? = null,
@SerialName("due_date") val dueDate: String,
@SerialName("estimated_cost") val estimatedCost: String? = null,
@SerialName("actual_cost") val actualCost: String? = null,
val notes: String? = null,
val archived: Boolean = false,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("show_completed_button") val showCompletedButton: Boolean = false,
@@ -43,7 +44,6 @@ data class TaskCreateRequest(
val frequency: Int,
@SerialName("interval_days") val intervalDays: Int? = null,
val priority: Int,
val status: Int,
@SerialName("due_date") val dueDate: String,
@SerialName("estimated_cost") val estimatedCost: String? = null
)
@@ -64,6 +64,7 @@ data class TaskDetail(
@SerialName("estimated_cost") val estimatedCost: String? = null,
@SerialName("actual_cost") val actualCost: String? = null,
val notes: String? = null,
val archived: Boolean = false,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("next_scheduled_date") val nextScheduledDate: String? = null,
@@ -78,14 +79,16 @@ data class TasksByResidenceResponse(
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>
@SerialName("done_tasks") val doneTasks: List<TaskDetail>,
@SerialName("archived_tasks") val archivedTasks: List<TaskDetail>
)
@Serializable
data class CategorizedTaskSummary(
val upcoming: Int,
@SerialName("in_progress") val inProgress: Int,
val done: Int
val done: Int,
val archived: Int
)
@Serializable
@@ -94,7 +97,8 @@ data class AllTasksResponse(
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>
@SerialName("done_tasks") val doneTasks: List<TaskDetail>,
@SerialName("archived_tasks") val archivedTasks: List<TaskDetail>
)
@Serializable

View File

@@ -27,6 +27,8 @@ data class TaskFrequency(
val id: Int,
val name: String,
@SerialName("display_name") val displayName: String,
@SerialName("day_span") val daySpan: Int? = null,
@SerialName("notify_days") val notifyDays: Int? = null
)
@Serializable

View File

@@ -10,9 +10,20 @@ expect fun getLocalhostAddress(): String
expect fun createHttpClient(): HttpClient
object ApiClient {
private val BASE_URL = "http://${getLocalhostAddress()}:8000/api"
val httpClient = createHttpClient()
fun getBaseUrl() = BASE_URL
/**
* Get the current base URL based on environment configuration.
* To change environment, update ApiConfig.CURRENT_ENV
*/
fun getBaseUrl(): String = ApiConfig.getBaseUrl()
/**
* Print current environment configuration
*/
init {
println("🌐 API Client initialized")
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
println("🔗 Base URL: ${getBaseUrl()}")
}
}

View File

@@ -0,0 +1,38 @@
package com.mycrib.shared.network
/**
* API Environment Configuration
*
* To switch between localhost and dev server, simply change the CURRENT_ENV value:
* - Environment.LOCAL for local development
* - Environment.DEV for remote dev server
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.LOCAL
enum class Environment {
LOCAL,
DEV
}
/**
* Get the base URL based on current environment and platform
*/
fun getBaseUrl(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "http://${getLocalhostAddress()}:8000/api"
Environment.DEV -> "https://mycrib.treytartt.com/api"
}
}
/**
* Get environment name for logging
*/
fun getEnvironmentName(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)"
Environment.DEV -> "Dev Server (mycrib.treytartt.com)"
}
}
}

View File

@@ -30,7 +30,11 @@ fun AddNewTaskDialog(
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
var frequency by remember { mutableStateOf(TaskFrequency(id = 0, name = "", displayName = "")) }
var frequency by remember { mutableStateOf(TaskFrequency(
id = 0, name = "", displayName = "",
daySpan = 0,
notifyDays = 0
)) }
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) }
var showFrequencyDropdown by remember { mutableStateOf(false) }
@@ -270,8 +274,7 @@ fun AddNewTaskDialog(
intervalDays = intervalDays.toIntOrNull(),
priority = priority.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null },
status = 9
estimatedCost = estimatedCost.ifBlank { null }
)
)
}

View File

@@ -0,0 +1,364 @@
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.repository.LookupsRepository
import com.mycrib.shared.models.MyResidencesResponse
import com.mycrib.shared.models.TaskCategory
import com.mycrib.shared.models.TaskCreateRequest
import com.mycrib.shared.models.TaskFrequency
import com.mycrib.shared.models.TaskPriority
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddNewTaskWithResidenceDialog(
residencesResponse: MyResidencesResponse,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit,
isLoading: Boolean = false,
errorMessage: String? = null
) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var intervalDays by remember { mutableStateOf("") }
var dueDate by remember { mutableStateOf("") }
var estimatedCost by remember { mutableStateOf("") }
var selectedResidenceId by remember { mutableStateOf(residencesResponse.residences.firstOrNull()?.id ?: 0) }
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
var frequency by remember { mutableStateOf(TaskFrequency(
id = 0, name = "", displayName = "",
daySpan = 0,
notifyDays = 0
)) }
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) }
var showResidenceDropdown by remember { mutableStateOf(false) }
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) }
var residenceError by remember { mutableStateOf(false) }
// Get data from LookupsRepository
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
// Set defaults when data loads
LaunchedEffect(frequencies) {
if (frequencies.isNotEmpty()) {
frequency = frequencies.first()
}
}
LaunchedEffect(priorities) {
if (priorities.isNotEmpty()) {
priority = priorities.first()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Task") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Residence Selector
ExposedDropdownMenuBox(
expanded = showResidenceDropdown,
onExpandedChange = { showResidenceDropdown = it }
) {
OutlinedTextField(
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
onValueChange = { },
label = { Text("Property *") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = residenceError,
supportingText = if (residenceError) {
{ Text("Property is required") }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
readOnly = true,
enabled = residencesResponse.residences.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showResidenceDropdown,
onDismissRequest = { showResidenceDropdown = false }
) {
residencesResponse.residences.forEach { residence ->
DropdownMenuItem(
text = { Text(residence.name) },
onClick = {
selectedResidenceId = residence.id
residenceError = false
showResidenceDropdown = false
}
)
}
}
}
// 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 = categories.find { it == category }?.name ?: "",
onValueChange = { },
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,
enabled = categories.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false }
) {
categories.forEach { cat ->
DropdownMenuItem(
text = { Text(cat.name) },
onClick = {
category = cat
categoryError = false
showCategoryDropdown = false
}
)
}
}
}
// Frequency
ExposedDropdownMenuBox(
expanded = showFrequencyDropdown,
onExpandedChange = { showFrequencyDropdown = it }
) {
OutlinedTextField(
value = frequencies.find { it == frequency }?.displayName ?: "",
onValueChange = { },
label = { Text("Frequency") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showFrequencyDropdown,
onDismissRequest = { showFrequencyDropdown = false }
) {
frequencies.forEach { freq ->
DropdownMenuItem(
text = { Text(freq.displayName) },
onClick = {
frequency = freq
showFrequencyDropdown = false
// Clear interval days if frequency is "once"
if (freq.name == "once") {
intervalDays = ""
}
}
)
}
}
}
// Interval Days (only for recurring tasks)
if (frequency.name != "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.name == priority.name }?.displayName ?: "",
onValueChange = { },
label = { Text("Priority") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
enabled = priorities.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showPriorityDropdown,
onDismissRequest = { showPriorityDropdown = false }
) {
priorities.forEach { prio ->
DropdownMenuItem(
text = { Text(prio.displayName) },
onClick = {
priority = prio
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
)
// Error message display
if (errorMessage != null) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp)
)
}
}
},
confirmButton = {
Button(
onClick = {
// Validation
var hasError = false
if (selectedResidenceId == 0) {
residenceError = true
hasError = true
}
if (title.isBlank()) {
titleError = true
hasError = true
}
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
dueDateError = true
hasError = true
}
if (!hasError) {
onCreate(
TaskCreateRequest(
residence = selectedResidenceId,
title = title,
description = description.ifBlank { null },
category = category.id,
frequency = frequency.id,
intervalDays = intervalDays.toIntOrNull(),
priority = priority.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)
)
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
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

@@ -372,7 +372,11 @@ fun TaskCardPreview() {
description = "Remove all debris from gutters and downspouts",
category = TaskCategory(id = 1, name = "maintenance", description = ""),
priority = TaskPriority(id = 2, name = "medium", displayName = "Medium", description = ""),
frequency = TaskFrequency(id = 1, name = "monthly", displayName = "Monthly"),
frequency = TaskFrequency(
id = 1, name = "monthly", displayName = "Monthly",
daySpan = 0,
notifyDays = 0
),
status = TaskStatus(id = 1, name = "pending", displayName = "Pending", description = ""),
dueDate = "2024-12-15",
estimatedCost = "150.00",

View File

@@ -0,0 +1,211 @@
package com.mycrib.android.ui.components.task
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
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.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mycrib.shared.models.AllTasksResponse
import com.mycrib.shared.models.TaskDetail
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TaskKanbanView(
upcomingTasks: List<TaskDetail>,
inProgressTasks: List<TaskDetail>,
doneTasks: List<TaskDetail>,
archivedTasks: List<TaskDetail>,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?
) {
val pagerState = rememberPagerState(pageCount = { 4 })
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
pageSpacing = 16.dp,
contentPadding = PaddingValues(horizontal = 16.dp)
) { page ->
when (page) {
0 -> TaskColumn(
title = "Upcoming",
icon = Icons.Default.CalendarToday,
color = MaterialTheme.colorScheme.primary,
count = upcomingTasks.size,
tasks = upcomingTasks,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = onMarkInProgress
)
1 -> TaskColumn(
title = "In Progress",
icon = Icons.Default.PlayCircle,
color = MaterialTheme.colorScheme.tertiary,
count = inProgressTasks.size,
tasks = inProgressTasks,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = null
)
2 -> TaskColumn(
title = "Done",
icon = Icons.Default.CheckCircle,
color = MaterialTheme.colorScheme.secondary,
count = doneTasks.size,
tasks = doneTasks,
onCompleteTask = null,
onEditTask = onEditTask,
onCancelTask = null,
onUncancelTask = null,
onMarkInProgress = null
)
3 -> TaskColumn(
title = "Archived",
icon = Icons.Default.Archive,
color = MaterialTheme.colorScheme.outline,
count = archivedTasks.size,
tasks = archivedTasks,
onCompleteTask = null,
onEditTask = onEditTask,
onCancelTask = null,
onUncancelTask = null,
onMarkInProgress = null
)
}
}
}
}
@Composable
private fun TaskColumn(
title: String,
icon: ImageVector,
color: Color,
count: Int,
tasks: List<TaskDetail>,
onCompleteTask: ((TaskDetail) -> Unit)?,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp)
)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(24.dp)
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = color
)
}
Surface(
color = color,
shape = CircleShape
) {
Text(
text = count.toString(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.surface
)
}
}
// Tasks List
if (tasks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color.copy(alpha = 0.3f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No tasks",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tasks, key = { it.id }) { task ->
TaskCard(
task = task,
onCompleteClick = if (onCompleteTask != null) {
{ onCompleteTask(task) }
} else null,
onEditClick = { onEditTask(task) },
onCancelClick = if (onCancelTask != null) {
{ onCancelTask(task) }
} else null,
onUncancelClick = if (onUncancelTask != null) {
{ onUncancelTask(task) }
} else null,
onMarkInProgressClick = if (onMarkInProgress != null) {
{ onMarkInProgress(task) }
} else null
)
}
}
}
}
}

View File

@@ -12,8 +12,11 @@ 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.AddNewTaskWithResidenceDialog
import com.mycrib.android.ui.components.CompleteTaskDialog
import com.mycrib.android.ui.components.task.TaskCard
import com.mycrib.android.ui.components.task.TaskKanbanView
import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.android.viewmodel.TaskCompletionViewModel
import com.mycrib.android.viewmodel.TaskViewModel
import com.mycrib.shared.models.TaskDetail
@@ -23,18 +26,23 @@ import com.mycrib.shared.network.ApiResult
@Composable
fun AllTasksScreen(
onNavigateToEditTask: (TaskDetail) -> Unit,
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
var showInProgressTasks by remember { mutableStateOf(false) }
var showDoneTasks by remember { mutableStateOf(false) }
val myResidencesState by residenceViewModel.myResidencesState.collectAsState()
val createTaskState by viewModel.taskAddNewCustomTaskState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
LaunchedEffect(Unit) {
viewModel.loadTasks()
residenceViewModel.loadMyResidences()
}
// Handle completion success
@@ -50,6 +58,28 @@ fun AllTasksScreen(
}
}
// Handle task creation success
LaunchedEffect(createTaskState) {
println("AllTasksScreen: createTaskState changed to $createTaskState")
when (createTaskState) {
is ApiResult.Success -> {
println("AllTasksScreen: Task created successfully, closing dialog and reloading tasks")
showNewTaskDialog = false
viewModel.resetAddTaskState()
viewModel.loadTasks()
}
is ApiResult.Error -> {
println("AllTasksScreen: Task creation error: ${(createTaskState as ApiResult.Error).message}")
}
is ApiResult.Loading -> {
println("AllTasksScreen: Task creation loading")
}
else -> {
println("AllTasksScreen: Task creation idle")
}
}
}
Scaffold(
topBar = {
TopAppBar(
@@ -59,6 +89,18 @@ fun AllTasksScreen(
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(
onClick = { showNewTaskDialog = true },
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Task"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
@@ -109,7 +151,8 @@ fun AllTasksScreen(
val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.upcomingTasks.isEmpty() &&
taskData.inProgressTasks.isEmpty() &&
taskData.doneTasks.isEmpty()
taskData.doneTasks.isEmpty() &&
taskData.archivedTasks.isEmpty()
if (hasNoTasks) {
Box(
@@ -120,211 +163,92 @@ fun AllTasksScreen(
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.CheckCircle,
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(64.dp),
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No tasks yet",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Add a task to a residence to get started",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
"Create your first task to get started",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} 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)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showNewTaskDialog = true },
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
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
)
Icon(Icons.Default.Add, contentDescription = null)
Text(
text = "Upcoming (${taskData.upcomingTasks.size})",
"Add Task",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp)
fontWeight = FontWeight.Bold
)
}
}
if (myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isEmpty()) {
Text(
"Add a property first from the Residences tab",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
// 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"
)
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
TaskKanbanView(
upcomingTasks = taskData.upcomingTasks,
inProgressTasks = taskData.inProgressTasks,
doneTasks = taskData.doneTasks,
archivedTasks = taskData.archivedTasks,
onCompleteTask = { task ->
selectedTask = task
showCompleteDialog = true
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
// viewModel.cancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onUncancelTask = { task ->
// viewModel.uncancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onMarkInProgress = { task ->
viewModel.markInProgress(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
}
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
)
}
}
}
)
}
}
}
@@ -355,34 +279,22 @@ fun AllTasksScreen(
}
)
}
}
@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
)
}
if (showNewTaskDialog && myResidencesState is ApiResult.Success) {
AddNewTaskWithResidenceDialog(
residencesResponse = (myResidencesState as ApiResult.Success).data,
onDismiss = {
showNewTaskDialog = false
viewModel.resetAddTaskState()
},
onCreate = { taskRequest ->
println("AllTasksScreen: onCreate called with request: $taskRequest")
viewModel.createNewTask(taskRequest)
},
isLoading = createTaskState is ApiResult.Loading,
errorMessage = if (createTaskState is ApiResult.Error) {
(createTaskState as ApiResult.Error).message
} else null
)
}
}

View File

@@ -300,7 +300,6 @@ fun EditTaskScreen(
category = selectedCategory!!.id,
frequency = selectedFrequency!!.id,
priority = selectedPriority!!.id,
status = selectedStatus!!.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)

View File

@@ -22,7 +22,8 @@ fun MainScreen(
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (com.mycrib.shared.models.TaskDetail) -> Unit
onNavigateToEditTask: (com.mycrib.shared.models.TaskDetail) -> Unit,
onAddTask: () -> Unit
) {
var selectedTab by remember { mutableStateOf(0) }
val navController = rememberNavController()
@@ -112,7 +113,8 @@ fun MainScreen(
composable<MainTabTasksRoute> {
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask
)
}
}

View File

@@ -20,6 +20,7 @@ import com.mycrib.android.ui.components.common.InfoCard
import com.mycrib.android.ui.components.residence.PropertyDetailItem
import com.mycrib.android.ui.components.residence.DetailRow
import com.mycrib.android.ui.components.task.TaskCard
import com.mycrib.android.ui.components.task.TaskKanbanView
import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.android.viewmodel.TaskCompletionViewModel
import com.mycrib.android.viewmodel.TaskViewModel
@@ -48,8 +49,6 @@ fun ResidenceDetailScreen(
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var showInProgressTasks by remember { mutableStateOf(false) }
var showDoneTasks by remember { mutableStateOf(false) }
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
@@ -394,7 +393,7 @@ fun ResidenceDetailScreen(
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
if (taskData.upcomingTasks.isEmpty() && taskData.inProgressTasks.isEmpty() && taskData.doneTasks.isEmpty()) {
if (taskData.upcomingTasks.isEmpty() && taskData.inProgressTasks.isEmpty() && taskData.doneTasks.isEmpty() && taskData.archivedTasks.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -427,137 +426,38 @@ fun ResidenceDetailScreen(
}
}
} else {
// Upcoming tasks section
items(taskData.upcomingTasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = {
onNavigateToEditTask(task)
},
onCancelClick = {
residenceViewModel.cancelTask(task.id)
},
onUncancelClick = null,
onMarkInProgressClick = {
taskViewModel.markInProgress(task.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
) {
TaskKanbanView(
upcomingTasks = taskData.upcomingTasks,
inProgressTasks = taskData.inProgressTasks,
doneTasks = taskData.doneTasks,
archivedTasks = taskData.archivedTasks,
onCompleteTask = { task ->
selectedTask = task
showCompleteDialog = true
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
residenceViewModel.cancelTask(task.id)
},
onUncancelTask = { task ->
residenceViewModel.uncancelTask(task.id)
},
onMarkInProgress = { task ->
taskViewModel.markInProgress(task.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
}
}
)
}
// In Progress tasks section
if (taskData.inProgressTasks.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showInProgressTasks = !showInProgressTasks }
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.PlayCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "In Progress (${taskData.inProgressTasks.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary
)
}
Icon(
if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showInProgressTasks) {
items(taskData.inProgressTasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = {
onNavigateToEditTask(task)
},
onCancelClick = {
residenceViewModel.cancelTask(task.id)
},
onUncancelClick = null,
onMarkInProgressClick = null
)
}
}
}
// Done tasks section
if (taskData.doneTasks.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showDoneTasks = !showDoneTasks }
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Done (${taskData.doneTasks.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Icon(
if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showDoneTasks) {
items(taskData.doneTasks) { task ->
TaskCard(
task = task,
onCompleteClick = null,
onEditClick = {
onNavigateToEditTask(task)
},
onCancelClick = null,
onUncancelClick = {
residenceViewModel.uncancelTask(task.id)
},
onMarkInProgressClick = null
)
}
)
}
}
}

View File

@@ -57,14 +57,30 @@ fun ResidencesScreen(
)
},
floatingActionButton = {
FloatingActionButton(
onClick = onAddResidence,
containerColor = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add Property")
// Only show FAB when there are properties
val hasResidences = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
if (hasResidences) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = onAddResidence,
containerColor = MaterialTheme.colorScheme.primary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 8.dp,
pressedElevation = 12.dp
)
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Property",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
},
floatingActionButtonPosition = FabPosition.End
) { paddingValues ->
when (myResidencesState) {
is ApiResult.Idle, is ApiResult.Loading -> {
@@ -119,7 +135,8 @@ fun ResidencesScreen(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Home,
@@ -137,6 +154,26 @@ fun ResidencesScreen(
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onAddResidence,
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Text(
"Add Property",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
} else {

View File

@@ -20,6 +20,7 @@ import com.mycrib.shared.network.ApiResult
@Composable
fun TasksScreen(
onNavigateBack: () -> Unit,
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
) {
@@ -55,14 +56,10 @@ fun TasksScreen(
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { /* TODO: Add task */ }) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
}
)
}
},
// No FAB on Tasks screen - tasks are added from within residences
) { paddingValues ->
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading -> {
@@ -107,7 +104,33 @@ fun TasksScreen(
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text("No tasks yet. Add one to get started!")
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
Text(
"Tasks are created from your properties.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Go to Residences tab to add a property, then add tasks to it!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(

View File

@@ -44,10 +44,13 @@ fun VerifyEmailScreen(
errorMessage = (verifyState as ApiResult.Error).message
isLoading = false
}
is ApiResult.Idle, is ApiResult.Loading -> {
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
}
is ApiResult.Idle -> {
// Do nothing - initial state, no loading indicator needed
}
else -> {}
}
}

View File

@@ -5,16 +5,88 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Bright color palette
private val BrightBlue = Color(0xFF007AFF)
private val BrightGreen = Color(0xFF34C759)
private val BrightOrange = Color(0xFFFF9500)
private val BrightRed = Color(0xFFFF3B30)
private val BrightPurple = Color(0xFFAF52DE)
private val BrightTeal = Color(0xFF5AC8FA)
// Light variations for containers
private val LightBlue = Color(0xFFE3F2FD)
private val LightGreen = Color(0xFFE8F5E9)
private val LightOrange = Color(0xFFFFF3E0)
// Dark variations
private val DarkBlue = Color(0xFF0A84FF)
private val DarkGreen = Color(0xFF30D158)
private val DarkOrange = Color(0xFFFF9F0A)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF03DAC6),
tertiary = Color(0xFF3700B3)
primary = DarkBlue,
onPrimary = Color.White,
primaryContainer = Color(0xFF003D75),
onPrimaryContainer = Color(0xFFD0E4FF),
secondary = DarkGreen,
onSecondary = Color.White,
secondaryContainer = Color(0xFF1B5E20),
onSecondaryContainer = Color(0xFFB9F6CA),
tertiary = DarkOrange,
onTertiary = Color.White,
tertiaryContainer = Color(0xFF663C00),
onTertiaryContainer = Color(0xFFFFE0B2),
error = BrightRed,
onError = Color.White,
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5),
surfaceVariant = Color(0xFF49454F),
onSurfaceVariant = Color(0xFFCAC4D0),
outline = Color(0xFF938F99),
outlineVariant = Color(0xFF49454F)
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF03DAC6),
tertiary = Color(0xFF3700B3)
primary = BrightBlue,
onPrimary = Color.White,
primaryContainer = LightBlue,
onPrimaryContainer = Color(0xFF001D35),
secondary = BrightGreen,
onSecondary = Color.White,
secondaryContainer = LightGreen,
onSecondaryContainer = Color(0xFF002106),
tertiary = BrightOrange,
onTertiary = Color.White,
tertiaryContainer = LightOrange,
onTertiaryContainer = Color(0xFF2B1700),
error = BrightRed,
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCAC4D0)
)
@Composable

View File

@@ -26,11 +26,14 @@ class TaskViewModel : ViewModel() {
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
fun loadTasks() {
println("TaskViewModel: loadTasks called")
viewModelScope.launch {
_tasksState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_tasksState.value = taskApi.getTasks(token)
val result = taskApi.getTasks(token)
println("TaskViewModel: loadTasks result: $result")
_tasksState.value = result
} else {
_tasksState.value = ApiResult.Error("Not authenticated", 401)
}
@@ -50,11 +53,17 @@ class TaskViewModel : ViewModel() {
}
fun createNewTask(request: TaskCreateRequest) {
println("TaskViewModel: createNewTask called with $request")
viewModelScope.launch {
println("TaskViewModel: Setting state to Loading")
_taskAddNewCustomTaskState.value = ApiResult.Loading
try {
_taskAddNewCustomTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request)
val result = taskApi.createTask(TokenStorage.getToken()!!, request)
println("TaskViewModel: API result: $result")
_taskAddNewCustomTaskState.value = result
} catch (e: Exception) {
println("TaskViewModel: Exception: ${e.message}")
e.printStackTrace()
_taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
}
}

View File

@@ -12,8 +12,6 @@ struct ResidenceDetailView: View {
@State private var showEditResidence = false
@State private var showEditTask = false
@State private var selectedTaskForEdit: TaskDetail?
@State private var showInProgressTasks = false
@State private var showDoneTasks = false
@State private var selectedTaskForComplete: TaskDetail?
var body: some View {
@@ -39,8 +37,6 @@ struct ResidenceDetailView: View {
if let tasksResponse = tasksResponse {
TasksSection(
tasksResponse: tasksResponse,
showInProgressTasks: $showInProgressTasks,
showDoneTasks: $showDoneTasks,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true

View File

@@ -145,12 +145,13 @@ struct TaskCard: View {
description: "Remove all debris from gutters",
category: TaskCategory(id: 1, name: "maintenance", description: ""),
priority: TaskPriority(id: 2, name: "medium", displayName: "", description: ""),
frequency: TaskFrequency(id: 1, name: "monthly", displayName: "30"),
frequency: TaskFrequency(id: 1, name: "monthly", displayName: "30", daySpan: 0, notifyDays: 0),
status: TaskStatus(id: 1, name: "pending", displayName: "", description: ""),
dueDate: "2024-12-15",
estimatedCost: "150.00",
actualCost: nil,
notes: nil,
archived: false,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
nextScheduledDate: nil,

View File

@@ -3,8 +3,6 @@ import ComposeApp
struct TasksSection: View {
let tasksResponse: TasksByResidenceResponse
@Binding var showInProgressTasks: Bool
@Binding var showDoneTasks: Bool
let onEditTask: (TaskDetail) -> Void
let onCancelTask: (TaskDetail) -> Void
let onUncancelTask: (TaskDetail) -> Void
@@ -13,104 +11,82 @@ struct TasksSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Tasks")
.font(.title2)
.fontWeight(.bold)
Text("Tasks")
.font(.title2)
.fontWeight(.bold)
Spacer()
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)
}
}
if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty {
if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty && tasksResponse.archivedTasks.isEmpty {
EmptyTasksView()
} else {
// Upcoming tasks
ForEach(tasksResponse.upcomingTasks, id: \.id) { task in
TaskCard(
task: task,
onEdit: { onEditTask(task) },
onCancel: { onCancelTask(task) },
onUncancel: nil,
onMarkInProgress: { onMarkInProgress(task) },
onComplete: { onCompleteTask(task) }
)
}
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) {
// Upcoming Column
TaskColumnView(
title: "Upcoming",
icon: "calendar",
color: .blue,
count: tasksResponse.upcomingTasks.count,
tasks: tasksResponse.upcomingTasks,
onEditTask: onEditTask,
onCancelTask: onCancelTask,
onUncancelTask: onUncancelTask,
onMarkInProgress: onMarkInProgress,
onCompleteTask: onCompleteTask
)
.frame(width: geometry.size.width - 48)
// In Progress tasks section
if !tasksResponse.inProgressTasks.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("In Progress (\(tasksResponse.inProgressTasks.count))", systemImage: "play.circle")
.font(.headline)
.foregroundColor(.orange)
// In Progress Column
TaskColumnView(
title: "In Progress",
icon: "play.circle",
color: .orange,
count: tasksResponse.inProgressTasks.count,
tasks: tasksResponse.inProgressTasks,
onEditTask: onEditTask,
onCancelTask: onCancelTask,
onUncancelTask: onUncancelTask,
onMarkInProgress: nil,
onCompleteTask: onCompleteTask
)
.frame(width: geometry.size.width - 48)
Spacer()
// Done Column
TaskColumnView(
title: "Done",
icon: "checkmark.circle",
color: .green,
count: tasksResponse.doneTasks.count,
tasks: tasksResponse.doneTasks,
onEditTask: onEditTask,
onCancelTask: nil,
onUncancelTask: nil,
onMarkInProgress: nil,
onCompleteTask: nil
)
.frame(width: geometry.size.width - 48)
Image(systemName: showInProgressTasks ? "chevron.up" : "chevron.down")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.top, 8)
.contentShape(Rectangle())
.onTapGesture {
showInProgressTasks.toggle()
}
if showInProgressTasks {
ForEach(tasksResponse.inProgressTasks, id: \.id) { task in
TaskCard(
task: task,
onEdit: { onEditTask(task) },
onCancel: { onCancelTask(task) },
onUncancel: nil,
onMarkInProgress: nil,
onComplete: { onCompleteTask(task) }
)
}
}
}
}
// Done tasks section
if !tasksResponse.doneTasks.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Done (\(tasksResponse.doneTasks.count))", systemImage: "checkmark.circle")
.font(.headline)
.foregroundColor(.green)
Spacer()
Image(systemName: showDoneTasks ? "chevron.up" : "chevron.down")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.top, 8)
.contentShape(Rectangle())
.onTapGesture {
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
)
}
// Archived Column
TaskColumnView(
title: "Archived",
icon: "archivebox",
color: .gray,
count: tasksResponse.archivedTasks.count,
tasks: tasksResponse.archivedTasks,
onEditTask: onEditTask,
onCancelTask: nil,
onUncancelTask: nil,
onMarkInProgress: nil,
onCompleteTask: nil
)
.frame(width: geometry.size.width - 48)
}
.scrollTargetLayout()
.padding(.horizontal, 16)
}
.scrollTargetBehavior(.viewAligned)
}
.frame(height: 500)
}
}
}
@@ -134,12 +110,13 @@ struct TasksSection: View {
description: "Remove all debris",
category: TaskCategory(id: 1, name: "maintenance", description: "General upkeep tasks"),
priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: "Standard priority"),
frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly"),
frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0),
status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: "Awaiting completion"),
dueDate: "2024-12-15",
estimatedCost: "150.00",
actualCost: nil,
notes: nil,
archived: false,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
nextScheduledDate: nil,
@@ -156,22 +133,22 @@ struct TasksSection: View {
description: "Kitchen sink fixed",
category: TaskCategory(id: 2, name: "plumbing", description: "Plumbing tasks"),
priority: TaskPriority(id: 3, name: "high", displayName: "High", description: "High priority"),
frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time"),
frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0),
status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: "Task completed"),
dueDate: "2024-11-01",
estimatedCost: "200.00",
actualCost: "185.00",
actualCost: nil,
notes: nil,
archived: false,
createdAt: "2024-10-01T00:00:00Z",
updatedAt: "2024-11-05T00:00:00Z",
nextScheduledDate: nil,
showCompletedButton: false,
completions: []
)
]
],
archivedTasks: []
),
showInProgressTasks: .constant(true),
showDoneTasks: .constant(true),
onEditTask: { _ in },
onCancelTask: { _ in },
onUncancelTask: { _ in },

View File

@@ -214,7 +214,6 @@ struct AddTaskView: View {
frequency: Int32(frequency.id),
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
priority: Int32(priority.id),
status: Int32(status.id),
dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
)

View File

@@ -0,0 +1,254 @@
import SwiftUI
import ComposeApp
struct AddTaskWithResidenceView: View {
@Binding var isPresented: Bool
let residences: [Residence]
@StateObject private var viewModel = TaskViewModel()
@StateObject private var lookupsManager = LookupsManager.shared
@FocusState private var focusedField: Field?
// Form fields
@State private var selectedResidence: Residence?
@State private var title: String = ""
@State private var description: String = ""
@State private var selectedCategory: TaskCategory?
@State private var selectedFrequency: TaskFrequency?
@State private var selectedPriority: TaskPriority?
@State private var selectedStatus: TaskStatus?
@State private var dueDate: Date = Date()
@State private var intervalDays: String = ""
@State private var estimatedCost: String = ""
// Validation errors
@State private var titleError: String = ""
@State private var residenceError: String = ""
enum Field {
case title, description, intervalDays, estimatedCost
}
var body: some View {
NavigationView {
if lookupsManager.isLoading {
VStack(spacing: 16) {
ProgressView()
Text("Loading...")
.foregroundColor(.secondary)
}
} else {
Form {
Section(header: Text("Property")) {
Picker("Property", selection: $selectedResidence) {
Text("Select Property").tag(nil as Residence?)
ForEach(residences, id: \.id) { residence in
Text(residence.name).tag(residence as Residence?)
}
}
if !residenceError.isEmpty {
Text(residenceError)
.font(.caption)
.foregroundColor(.red)
}
}
Section(header: Text("Task Details")) {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
if !titleError.isEmpty {
Text(titleError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
.focused($focusedField, equals: .description)
}
Section(header: Text("Category")) {
Picker("Category", selection: $selectedCategory) {
Text("Select Category").tag(nil as TaskCategory?)
ForEach(lookupsManager.taskCategories, id: \.id) { category in
Text(category.name.capitalized).tag(category as TaskCategory?)
}
}
}
Section(header: Text("Scheduling")) {
Picker("Frequency", selection: $selectedFrequency) {
Text("Select Frequency").tag(nil as TaskFrequency?)
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
Text(frequency.displayName).tag(frequency as TaskFrequency?)
}
}
if selectedFrequency?.name != "once" {
TextField("Custom Interval (days, optional)", text: $intervalDays)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
}
Section(header: Text("Priority & Status")) {
Picker("Priority", selection: $selectedPriority) {
Text("Select Priority").tag(nil as TaskPriority?)
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
Text(priority.displayName).tag(priority as TaskPriority?)
}
}
Picker("Status", selection: $selectedStatus) {
Text("Select Status").tag(nil as TaskStatus?)
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
Text(status.displayName).tag(status as TaskStatus?)
}
}
}
Section(header: Text("Cost")) {
TextField("Estimated Cost (optional)", text: $estimatedCost)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
}
.navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(viewModel.isLoading)
}
}
.onAppear {
setDefaults()
}
.onChange(of: viewModel.taskCreated) { created in
if created {
isPresented = false
}
}
}
}
}
private func setDefaults() {
if selectedResidence == nil && !residences.isEmpty {
selectedResidence = residences.first
}
if selectedCategory == nil && !lookupsManager.taskCategories.isEmpty {
selectedCategory = lookupsManager.taskCategories.first
}
if selectedFrequency == nil && !lookupsManager.taskFrequencies.isEmpty {
selectedFrequency = lookupsManager.taskFrequencies.first { $0.name == "once" } ?? lookupsManager.taskFrequencies.first
}
if selectedPriority == nil && !lookupsManager.taskPriorities.isEmpty {
selectedPriority = lookupsManager.taskPriorities.first { $0.name == "medium" } ?? lookupsManager.taskPriorities.first
}
if selectedStatus == nil && !lookupsManager.taskStatuses.isEmpty {
selectedStatus = lookupsManager.taskStatuses.first { $0.name == "pending" } ?? lookupsManager.taskStatuses.first
}
}
private func validateForm() -> Bool {
var isValid = true
if selectedResidence == nil {
residenceError = "Property is required"
isValid = false
} else {
residenceError = ""
}
if title.isEmpty {
titleError = "Title is required"
isValid = false
} else {
titleError = ""
}
if selectedCategory == nil {
viewModel.errorMessage = "Please select a category"
isValid = false
}
if selectedFrequency == nil {
viewModel.errorMessage = "Please select a frequency"
isValid = false
}
if selectedPriority == nil {
viewModel.errorMessage = "Please select a priority"
isValid = false
}
if selectedStatus == nil {
viewModel.errorMessage = "Please select a status"
isValid = false
}
return isValid
}
private func submitForm() {
guard validateForm() else { return }
guard let residence = selectedResidence,
let category = selectedCategory,
let frequency = selectedFrequency,
let priority = selectedPriority,
let status = selectedStatus else {
return
}
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dueDateString = dateFormatter.string(from: dueDate)
let request = TaskCreateRequest(
residence: Int32(residence.id),
title: title,
description: description.isEmpty ? nil : description,
category: Int32(category.id),
frequency: Int32(frequency.id),
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
priority: Int32(priority.id),
dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
)
viewModel.createTask(request: request) { success in
if success {
// View will dismiss automatically via onChange
}
}
}
}
#Preview {
AddTaskWithResidenceView(isPresented: .constant(true), residences: [])
}

View File

@@ -3,21 +3,32 @@ import ComposeApp
struct AllTasksView: View {
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
@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 selectedTaskForComplete: TaskDetail?
private var hasNoTasks: Bool {
guard let response = tasksResponse else { return true }
return response.upcomingTasks.isEmpty &&
response.inProgressTasks.isEmpty &&
response.doneTasks.isEmpty &&
response.archivedTasks.isEmpty
}
private var hasTasks: Bool {
!hasNoTasks
}
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
if isLoadingTasks {
ProgressView()
} else if let error = tasksError {
@@ -25,68 +36,179 @@ struct AllTasksView: View {
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
if hasNoTasks {
// Empty state with big button
VStack(spacing: 24) {
Spacer()
Image(systemName: "checklist")
.font(.system(size: 64))
.foregroundStyle(.blue.opacity(0.6))
Text("No tasks yet")
.font(.title2)
.fontWeight(.semibold)
Text("Create your first task to get started")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button(action: {
showAddTask = true
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
Text("Add Task")
.fontWeight(.semibold)
}
)
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 48)
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
if residenceViewModel.myResidences?.residences.isEmpty ?? true {
Text("Add a property first from the Residences tab")
.font(.caption)
.foregroundColor(.red)
}
Spacer()
}
.padding()
} else {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) {
// Upcoming Column
TaskColumnView(
title: "Upcoming",
icon: "calendar",
color: .blue,
count: tasksResponse.upcomingTasks.count,
tasks: tasksResponse.upcomingTasks,
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
}
)
.frame(width: geometry.size.width - 48)
// In Progress Column
TaskColumnView(
title: "In Progress",
icon: "play.circle",
color: .orange,
count: tasksResponse.inProgressTasks.count,
tasks: tasksResponse.inProgressTasks,
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: nil,
onCompleteTask: { task in
selectedTaskForComplete = task
}
)
.frame(width: geometry.size.width - 48)
// Done Column
TaskColumnView(
title: "Done",
icon: "checkmark.circle",
color: .green,
count: tasksResponse.doneTasks.count,
tasks: tasksResponse.doneTasks,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: nil,
onUncancelTask: nil,
onMarkInProgress: nil,
onCompleteTask: nil
)
.frame(width: geometry.size.width - 48)
// Archived Column
TaskColumnView(
title: "Archived",
icon: "archivebox",
color: .gray,
count: tasksResponse.archivedTasks.count,
tasks: tasksResponse.archivedTasks,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: nil,
onUncancelTask: nil,
onMarkInProgress: nil,
onCompleteTask: nil
)
.frame(width: geometry.size.width - 48)
}
.scrollTargetLayout()
.padding(.horizontal, 16)
}
.scrollTargetBehavior(.viewAligned)
}
.padding(.bottom)
}
}
}
.ignoresSafeArea(edges: .bottom)
.navigationTitle("All Tasks")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showAddTask = true
}) {
Image(systemName: "plus")
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
}
}
.sheet(isPresented: $showAddTask) {
AddTaskWithResidenceView(
isPresented: $showAddTask,
residences: residenceViewModel.myResidences?.residences.toResidences() ?? [],
)
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
@@ -98,6 +220,11 @@ struct AllTasksView: View {
loadAllTasks()
}
}
.onChange(of: showAddTask) { isShowing in
if !isShowing {
loadAllTasks()
}
}
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadAllTasks()
@@ -105,155 +232,118 @@ struct AllTasksView: View {
}
.onAppear {
loadAllTasks()
residenceViewModel.loadMyResidences()
}
}
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
}
}
}
}
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 TaskColumnView: View {
let title: String
let icon: String
let color: Color
let count: Int
let tasks: [TaskDetail]
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(spacing: 0) {
// Tasks List
ScrollView {
VStack(spacing: 16) {
// Header
HStack(spacing: 8) {
Image(systemName: icon)
.font(.headline)
.foregroundColor(color)
Text(title)
.font(.headline)
.foregroundColor(color)
Spacer()
Text("\(count)")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color)
.cornerRadius(12)
}
if tasks.isEmpty {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundColor(color.opacity(0.3))
Text("No tasks")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
} else {
ForEach(tasks, id: \.id) { task in
TaskCard(
task: task,
onEdit: { onEditTask(task) },
onCancel: onCancelTask != nil ? { onCancelTask?(task) } : nil,
onUncancel: onUncancelTask != nil ? { onUncancelTask?(task) } : nil,
onMarkInProgress: onMarkInProgress != nil ? { onMarkInProgress?(task) } : nil,
onComplete: onCompleteTask != nil ? { onCompleteTask?(task) } : nil
)
}
}
}
}
}
}
}
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
// Extension to apply corner radius to specific corners
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}
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
)
}
}
}
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
@@ -262,3 +352,37 @@ struct AllTasksSectionView: View {
AllTasksView()
}
}
extension Array where Element == ResidenceWithTasks {
/// Converts an array of ResidenceWithTasks into an array of Residence.
/// Adjust the mapping inside as needed to match your model initializers.
func toResidences() -> [Residence] {
return self.map { item in
return Residence(
id: item.id,
owner: KotlinInt(value: item.owner),
ownerUsername: item.ownerUsername,
name: item.name,
propertyType: item.propertyType,
streetAddress: item.streetAddress,
apartmentUnit: item.apartmentUnit,
city: item.city,
stateProvince: item.stateProvince,
postalCode: item.postalCode,
country: item.country,
bedrooms: item.bedrooms != nil ? KotlinInt(nonretainedObject: item.bedrooms!) : nil,
bathrooms: item.bathrooms != nil ? KotlinFloat(float: Float(item.bathrooms!)) : nil,
squareFootage: item.squareFootage != nil ? KotlinInt(nonretainedObject: item.squareFootage!) : nil,
lotSize: item.lotSize != nil ? KotlinFloat(float: Float(item.lotSize!)) : nil,
yearBuilt: item.yearBuilt != nil ? KotlinInt(nonretainedObject: item.yearBuilt!) : nil,
description: item.description,
purchaseDate: item.purchaseDate,
purchasePrice: item.purchasePrice,
isPrimary: item.isPrimary,
createdAt: item.createdAt,
updatedAt: item.updatedAt
)
}
}
}

View File

@@ -149,7 +149,6 @@ struct EditTaskView: View {
frequency: frequency.id,
intervalDays: nil,
priority: priority.id,
status: status.id,
dueDate: dueDate,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
)