wip
This commit is contained in:
255
ENVIRONMENT_SETUP.md
Normal file
255
ENVIRONMENT_SETUP.md
Normal 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
64
QUICK_START.md
Normal 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. ✨
|
||||
@@ -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() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +300,6 @@ fun EditTaskScreen(
|
||||
category = selectedCategory!!.id,
|
||||
frequency = selectedFrequency!!.id,
|
||||
priority = selectedPriority!!.id,
|
||||
status = selectedStatus!!.id,
|
||||
dueDate = dueDate,
|
||||
estimatedCost = estimatedCost.ifBlank { null }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
254
iosApp/iosApp/Task/AddTaskWithResidenceView.swift
Normal file
254
iosApp/iosApp/Task/AddTaskWithResidenceView.swift
Normal 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: [])
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user