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.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.safeContentPadding
|
import androidx.compose.foundation.layout.safeContentPadding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,9 +34,18 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.mycrib.android.ui.screens.MainScreen
|
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.navigation.*
|
||||||
import com.mycrib.repository.LookupsRepository
|
import com.mycrib.repository.LookupsRepository
|
||||||
import com.mycrib.shared.models.Residence
|
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 com.mycrib.storage.TokenStorage
|
||||||
|
|
||||||
import mycrib.composeapp.generated.resources.Res
|
import mycrib.composeapp.generated.resources.Res
|
||||||
@@ -55,12 +66,12 @@ fun App() {
|
|||||||
|
|
||||||
if (hasToken) {
|
if (hasToken) {
|
||||||
// Fetch current user to check verification status
|
// Fetch current user to check verification status
|
||||||
val authApi = com.mycrib.shared.network.AuthApi()
|
val authApi = AuthApi()
|
||||||
val token = TokenStorage.getToken()
|
val token = TokenStorage.getToken()
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
when (val result = authApi.getCurrentUser(token)) {
|
when (val result = authApi.getCurrentUser(token)) {
|
||||||
is com.mycrib.shared.network.ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
isVerified = result.data.verified
|
isVerified = result.data.verified
|
||||||
LookupsRepository.initialize()
|
LookupsRepository.initialize()
|
||||||
}
|
}
|
||||||
@@ -76,33 +87,34 @@ fun App() {
|
|||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCheckingAuth) {
|
MyCribTheme {
|
||||||
// Show loading screen while checking auth
|
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(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
androidx.compose.foundation.layout.Box(
|
NavHost(
|
||||||
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(
|
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination
|
startDestination = startDestination
|
||||||
) {
|
) {
|
||||||
@@ -187,6 +199,11 @@ fun App() {
|
|||||||
onAddResidence = {
|
onAddResidence = {
|
||||||
navController.navigate(AddResidenceRoute)
|
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 ->
|
onNavigateToEditResidence = { residence ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
EditResidenceRoute(
|
EditResidenceRoute(
|
||||||
@@ -399,16 +416,20 @@ fun App() {
|
|||||||
composable<EditTaskRoute> { backStackEntry ->
|
composable<EditTaskRoute> { backStackEntry ->
|
||||||
val route = backStackEntry.toRoute<EditTaskRoute>()
|
val route = backStackEntry.toRoute<EditTaskRoute>()
|
||||||
EditTaskScreen(
|
EditTaskScreen(
|
||||||
task = com.mycrib.shared.models.TaskDetail(
|
task = TaskDetail(
|
||||||
id = route.taskId,
|
id = route.taskId,
|
||||||
residence = route.residenceId,
|
residence = route.residenceId,
|
||||||
title = route.title,
|
title = route.title,
|
||||||
description = route.description,
|
description = route.description,
|
||||||
category = com.mycrib.shared.models.TaskCategory(route.categoryId, route.categoryName),
|
category = TaskCategory(route.categoryId, route.categoryName),
|
||||||
frequency = com.mycrib.shared.models.TaskFrequency(route.frequencyId, route.frequencyName, ""),
|
frequency = TaskFrequency(
|
||||||
priority = com.mycrib.shared.models.TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
|
route.frequencyId, route.frequencyName, "",
|
||||||
|
daySpan = 0,
|
||||||
|
notifyDays = 0
|
||||||
|
),
|
||||||
|
priority = TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
|
||||||
status = route.statusId?.let {
|
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,
|
dueDate = route.dueDate,
|
||||||
estimatedCost = route.estimatedCost,
|
estimatedCost = route.estimatedCost,
|
||||||
@@ -426,7 +447,7 @@ fun App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable<ProfileRoute> {
|
composable<ProfileRoute> {
|
||||||
com.mycrib.android.ui.screens.ProfileScreen(
|
ProfileScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
@@ -443,6 +464,7 @@ fun App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ data class CustomTask (
|
|||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val category: String,
|
val category: String,
|
||||||
val priority: String,
|
val priority: String,
|
||||||
val status: String,
|
val status: String? = null,
|
||||||
@SerialName("due_date") val dueDate: String,
|
@SerialName("due_date") val dueDate: String,
|
||||||
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
||||||
@SerialName("actual_cost") val actualCost: String? = null,
|
@SerialName("actual_cost") val actualCost: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
val archived: Boolean = false,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("updated_at") val updatedAt: String,
|
@SerialName("updated_at") val updatedAt: String,
|
||||||
@SerialName("show_completed_button") val showCompletedButton: Boolean = false,
|
@SerialName("show_completed_button") val showCompletedButton: Boolean = false,
|
||||||
@@ -43,7 +44,6 @@ data class TaskCreateRequest(
|
|||||||
val frequency: Int,
|
val frequency: Int,
|
||||||
@SerialName("interval_days") val intervalDays: Int? = null,
|
@SerialName("interval_days") val intervalDays: Int? = null,
|
||||||
val priority: Int,
|
val priority: Int,
|
||||||
val status: Int,
|
|
||||||
@SerialName("due_date") val dueDate: String,
|
@SerialName("due_date") val dueDate: String,
|
||||||
@SerialName("estimated_cost") val estimatedCost: String? = null
|
@SerialName("estimated_cost") val estimatedCost: String? = null
|
||||||
)
|
)
|
||||||
@@ -64,6 +64,7 @@ data class TaskDetail(
|
|||||||
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
||||||
@SerialName("actual_cost") val actualCost: String? = null,
|
@SerialName("actual_cost") val actualCost: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
val archived: Boolean = false,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("updated_at") val updatedAt: String,
|
@SerialName("updated_at") val updatedAt: String,
|
||||||
@SerialName("next_scheduled_date") val nextScheduledDate: String? = null,
|
@SerialName("next_scheduled_date") val nextScheduledDate: String? = null,
|
||||||
@@ -78,14 +79,16 @@ data class TasksByResidenceResponse(
|
|||||||
val summary: CategorizedTaskSummary,
|
val summary: CategorizedTaskSummary,
|
||||||
@SerialName("upcoming_tasks") val upcomingTasks: List<TaskDetail>,
|
@SerialName("upcoming_tasks") val upcomingTasks: List<TaskDetail>,
|
||||||
@SerialName("in_progress_tasks") val inProgressTasks: 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
|
@Serializable
|
||||||
data class CategorizedTaskSummary(
|
data class CategorizedTaskSummary(
|
||||||
val upcoming: Int,
|
val upcoming: Int,
|
||||||
@SerialName("in_progress") val inProgress: Int,
|
@SerialName("in_progress") val inProgress: Int,
|
||||||
val done: Int
|
val done: Int,
|
||||||
|
val archived: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -94,7 +97,8 @@ data class AllTasksResponse(
|
|||||||
val summary: CategorizedTaskSummary,
|
val summary: CategorizedTaskSummary,
|
||||||
@SerialName("upcoming_tasks") val upcomingTasks: List<TaskDetail>,
|
@SerialName("upcoming_tasks") val upcomingTasks: List<TaskDetail>,
|
||||||
@SerialName("in_progress_tasks") val inProgressTasks: 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
|
@Serializable
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class TaskFrequency(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val name: String,
|
val name: String,
|
||||||
@SerialName("display_name") val displayName: String,
|
@SerialName("display_name") val displayName: String,
|
||||||
|
@SerialName("day_span") val daySpan: Int? = null,
|
||||||
|
@SerialName("notify_days") val notifyDays: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ expect fun getLocalhostAddress(): String
|
|||||||
expect fun createHttpClient(): HttpClient
|
expect fun createHttpClient(): HttpClient
|
||||||
|
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
private val BASE_URL = "http://${getLocalhostAddress()}:8000/api"
|
|
||||||
|
|
||||||
val httpClient = createHttpClient()
|
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 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 priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) }
|
||||||
|
|
||||||
var showFrequencyDropdown by remember { mutableStateOf(false) }
|
var showFrequencyDropdown by remember { mutableStateOf(false) }
|
||||||
@@ -270,8 +274,7 @@ fun AddNewTaskDialog(
|
|||||||
intervalDays = intervalDays.toIntOrNull(),
|
intervalDays = intervalDays.toIntOrNull(),
|
||||||
priority = priority.id,
|
priority = priority.id,
|
||||||
dueDate = dueDate,
|
dueDate = dueDate,
|
||||||
estimatedCost = estimatedCost.ifBlank { null },
|
estimatedCost = estimatedCost.ifBlank { null }
|
||||||
status = 9
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
description = "Remove all debris from gutters and downspouts",
|
||||||
category = TaskCategory(id = 1, name = "maintenance", description = ""),
|
category = TaskCategory(id = 1, name = "maintenance", description = ""),
|
||||||
priority = TaskPriority(id = 2, name = "medium", displayName = "Medium", 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 = ""),
|
status = TaskStatus(id = 1, name = "pending", displayName = "Pending", description = ""),
|
||||||
dueDate = "2024-12-15",
|
dueDate = "2024-12-15",
|
||||||
estimatedCost = "150.00",
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.CompleteTaskDialog
|
||||||
import com.mycrib.android.ui.components.task.TaskCard
|
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.TaskCompletionViewModel
|
||||||
import com.mycrib.android.viewmodel.TaskViewModel
|
import com.mycrib.android.viewmodel.TaskViewModel
|
||||||
import com.mycrib.shared.models.TaskDetail
|
import com.mycrib.shared.models.TaskDetail
|
||||||
@@ -23,18 +26,23 @@ import com.mycrib.shared.network.ApiResult
|
|||||||
@Composable
|
@Composable
|
||||||
fun AllTasksScreen(
|
fun AllTasksScreen(
|
||||||
onNavigateToEditTask: (TaskDetail) -> Unit,
|
onNavigateToEditTask: (TaskDetail) -> Unit,
|
||||||
|
onAddTask: () -> Unit = {},
|
||||||
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
||||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
|
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
||||||
|
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||||
) {
|
) {
|
||||||
val tasksState by viewModel.tasksState.collectAsState()
|
val tasksState by viewModel.tasksState.collectAsState()
|
||||||
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||||
var showInProgressTasks by remember { mutableStateOf(false) }
|
val myResidencesState by residenceViewModel.myResidencesState.collectAsState()
|
||||||
var showDoneTasks by remember { mutableStateOf(false) }
|
val createTaskState by viewModel.taskAddNewCustomTaskState.collectAsState()
|
||||||
|
|
||||||
var showCompleteDialog by remember { mutableStateOf(false) }
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var showNewTaskDialog by remember { mutableStateOf(false) }
|
||||||
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadTasks()
|
viewModel.loadTasks()
|
||||||
|
residenceViewModel.loadMyResidences()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle completion success
|
// 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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -59,6 +89,18 @@ fun AllTasksScreen(
|
|||||||
fontWeight = FontWeight.Bold
|
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(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
)
|
)
|
||||||
@@ -109,7 +151,8 @@ fun AllTasksScreen(
|
|||||||
val taskData = (tasksState as ApiResult.Success).data
|
val taskData = (tasksState as ApiResult.Success).data
|
||||||
val hasNoTasks = taskData.upcomingTasks.isEmpty() &&
|
val hasNoTasks = taskData.upcomingTasks.isEmpty() &&
|
||||||
taskData.inProgressTasks.isEmpty() &&
|
taskData.inProgressTasks.isEmpty() &&
|
||||||
taskData.doneTasks.isEmpty()
|
taskData.doneTasks.isEmpty() &&
|
||||||
|
taskData.archivedTasks.isEmpty()
|
||||||
|
|
||||||
if (hasNoTasks) {
|
if (hasNoTasks) {
|
||||||
Box(
|
Box(
|
||||||
@@ -120,211 +163,92 @@ fun AllTasksScreen(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
|
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.padding(24.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.Assignment,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(64.dp),
|
modifier = Modifier.size(80.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"No tasks yet",
|
"No tasks yet",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Add a task to a residence to get started",
|
"Create your first task to get started",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
Button(
|
||||||
} else {
|
onClick = { showNewTaskDialog = true },
|
||||||
LazyColumn(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth(0.7f)
|
||||||
.fillMaxSize()
|
.height(56.dp),
|
||||||
.padding(paddingValues),
|
enabled = myResidencesState is ApiResult.Success &&
|
||||||
contentPadding = PaddingValues(
|
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 16.dp,
|
|
||||||
bottom = 96.dp
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
// Task summary pills
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
TaskSummaryPill(
|
|
||||||
count = taskData.summary.upcoming,
|
|
||||||
label = "Upcoming",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
TaskSummaryPill(
|
|
||||||
count = taskData.summary.inProgress,
|
|
||||||
label = "In Progress",
|
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
|
||||||
)
|
|
||||||
TaskSummaryPill(
|
|
||||||
count = taskData.summary.done,
|
|
||||||
label = "Done",
|
|
||||||
color = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upcoming tasks header
|
|
||||||
if (taskData.upcomingTasks.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
Icons.Default.CalendarToday,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = "Upcoming (${taskData.upcomingTasks.size})",
|
"Add Task",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
} else {
|
||||||
items(taskData.upcomingTasks) { task ->
|
Box(
|
||||||
TaskCard(
|
modifier = Modifier
|
||||||
task = task,
|
.fillMaxSize()
|
||||||
onCompleteClick = {
|
.padding(paddingValues)
|
||||||
selectedTask = task
|
) {
|
||||||
showCompleteDialog = true
|
TaskKanbanView(
|
||||||
},
|
upcomingTasks = taskData.upcomingTasks,
|
||||||
onEditClick = { onNavigateToEditTask(task) },
|
inProgressTasks = taskData.inProgressTasks,
|
||||||
onCancelClick = { /* TODO */ },
|
doneTasks = taskData.doneTasks,
|
||||||
onUncancelClick = null,
|
archivedTasks = taskData.archivedTasks,
|
||||||
onMarkInProgressClick = {
|
onCompleteTask = { task ->
|
||||||
viewModel.markInProgress(task.id) { success ->
|
selectedTask = task
|
||||||
if (success) {
|
showCompleteDialog = true
|
||||||
viewModel.loadTasks()
|
},
|
||||||
}
|
onEditTask = { task ->
|
||||||
}
|
onNavigateToEditTask(task)
|
||||||
}
|
},
|
||||||
)
|
onCancelTask = { task ->
|
||||||
}
|
// viewModel.cancelTask(task.id) { _ ->
|
||||||
|
// viewModel.loadTasks()
|
||||||
// In Progress section (collapsible)
|
// }
|
||||||
if (taskData.inProgressTasks.isNotEmpty()) {
|
},
|
||||||
item {
|
onUncancelTask = { task ->
|
||||||
Card(
|
// viewModel.uncancelTask(task.id) { _ ->
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// viewModel.loadTasks()
|
||||||
onClick = { showInProgressTasks = !showInProgressTasks }
|
// }
|
||||||
) {
|
},
|
||||||
Row(
|
onMarkInProgress = { task ->
|
||||||
modifier = Modifier
|
viewModel.markInProgress(task.id) { success ->
|
||||||
.fillMaxWidth()
|
if (success) {
|
||||||
.padding(16.dp),
|
viewModel.loadTasks()
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "In Progress (${taskData.inProgressTasks.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = if (showInProgressTasks) "Collapse" else "Expand"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
if (showInProgressTasks) {
|
|
||||||
items(taskData.inProgressTasks) { task ->
|
|
||||||
TaskCard(
|
|
||||||
task = task,
|
|
||||||
onCompleteClick = {
|
|
||||||
selectedTask = task
|
|
||||||
showCompleteDialog = true
|
|
||||||
},
|
|
||||||
onEditClick = { onNavigateToEditTask(task) },
|
|
||||||
onCancelClick = { /* TODO */ },
|
|
||||||
onUncancelClick = null,
|
|
||||||
onMarkInProgressClick = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Done section (collapsible)
|
|
||||||
if (taskData.doneTasks.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = { showDoneTasks = !showDoneTasks }
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Done (${taskData.doneTasks.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = if (showDoneTasks) "Collapse" else "Expand"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDoneTasks) {
|
|
||||||
items(taskData.doneTasks) { task ->
|
|
||||||
TaskCard(
|
|
||||||
task = task,
|
|
||||||
onCompleteClick = null,
|
|
||||||
onEditClick = { onNavigateToEditTask(task) },
|
|
||||||
onCancelClick = null,
|
|
||||||
onUncancelClick = null,
|
|
||||||
onMarkInProgressClick = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,34 +279,22 @@ fun AllTasksScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
if (showNewTaskDialog && myResidencesState is ApiResult.Success) {
|
||||||
private fun TaskSummaryPill(
|
AddNewTaskWithResidenceDialog(
|
||||||
count: Int,
|
residencesResponse = (myResidencesState as ApiResult.Success).data,
|
||||||
label: String,
|
onDismiss = {
|
||||||
color: androidx.compose.ui.graphics.Color
|
showNewTaskDialog = false
|
||||||
) {
|
viewModel.resetAddTaskState()
|
||||||
Surface(
|
},
|
||||||
color = color.copy(alpha = 0.1f),
|
onCreate = { taskRequest ->
|
||||||
shape = MaterialTheme.shapes.small
|
println("AllTasksScreen: onCreate called with request: $taskRequest")
|
||||||
) {
|
viewModel.createNewTask(taskRequest)
|
||||||
Row(
|
},
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
isLoading = createTaskState is ApiResult.Loading,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
errorMessage = if (createTaskState is ApiResult.Error) {
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
(createTaskState as ApiResult.Error).message
|
||||||
) {
|
} else null
|
||||||
Text(
|
)
|
||||||
text = count.toString(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = color,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = color
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ fun EditTaskScreen(
|
|||||||
category = selectedCategory!!.id,
|
category = selectedCategory!!.id,
|
||||||
frequency = selectedFrequency!!.id,
|
frequency = selectedFrequency!!.id,
|
||||||
priority = selectedPriority!!.id,
|
priority = selectedPriority!!.id,
|
||||||
status = selectedStatus!!.id,
|
|
||||||
dueDate = dueDate,
|
dueDate = dueDate,
|
||||||
estimatedCost = estimatedCost.ifBlank { null }
|
estimatedCost = estimatedCost.ifBlank { null }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ fun MainScreen(
|
|||||||
onResidenceClick: (Int) -> Unit,
|
onResidenceClick: (Int) -> Unit,
|
||||||
onAddResidence: () -> Unit,
|
onAddResidence: () -> Unit,
|
||||||
onNavigateToEditResidence: (Residence) -> 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) }
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@@ -112,7 +113,8 @@ fun MainScreen(
|
|||||||
composable<MainTabTasksRoute> {
|
composable<MainTabTasksRoute> {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AllTasksScreen(
|
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.PropertyDetailItem
|
||||||
import com.mycrib.android.ui.components.residence.DetailRow
|
import com.mycrib.android.ui.components.residence.DetailRow
|
||||||
import com.mycrib.android.ui.components.task.TaskCard
|
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.ResidenceViewModel
|
||||||
import com.mycrib.android.viewmodel.TaskCompletionViewModel
|
import com.mycrib.android.viewmodel.TaskCompletionViewModel
|
||||||
import com.mycrib.android.viewmodel.TaskViewModel
|
import com.mycrib.android.viewmodel.TaskViewModel
|
||||||
@@ -48,8 +49,6 @@ fun ResidenceDetailScreen(
|
|||||||
var showCompleteDialog by remember { mutableStateOf(false) }
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
var showNewTaskDialog by remember { mutableStateOf(false) }
|
var showNewTaskDialog by remember { mutableStateOf(false) }
|
||||||
var showInProgressTasks by remember { mutableStateOf(false) }
|
|
||||||
var showDoneTasks by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(residenceId) {
|
LaunchedEffect(residenceId) {
|
||||||
residenceViewModel.getResidence(residenceId) { result ->
|
residenceViewModel.getResidence(residenceId) { result ->
|
||||||
@@ -394,7 +393,7 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
val taskData = (tasksState as ApiResult.Success).data
|
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 {
|
item {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -427,137 +426,38 @@ fun ResidenceDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Upcoming tasks section
|
item {
|
||||||
items(taskData.upcomingTasks) { task ->
|
Box(
|
||||||
TaskCard(
|
modifier = Modifier
|
||||||
task = task,
|
.fillMaxWidth()
|
||||||
onCompleteClick = {
|
.height(500.dp)
|
||||||
selectedTask = task
|
) {
|
||||||
showCompleteDialog = true
|
TaskKanbanView(
|
||||||
},
|
upcomingTasks = taskData.upcomingTasks,
|
||||||
onEditClick = {
|
inProgressTasks = taskData.inProgressTasks,
|
||||||
onNavigateToEditTask(task)
|
doneTasks = taskData.doneTasks,
|
||||||
},
|
archivedTasks = taskData.archivedTasks,
|
||||||
onCancelClick = {
|
onCompleteTask = { task ->
|
||||||
residenceViewModel.cancelTask(task.id)
|
selectedTask = task
|
||||||
},
|
showCompleteDialog = true
|
||||||
onUncancelClick = null,
|
},
|
||||||
onMarkInProgressClick = {
|
onEditTask = { task ->
|
||||||
taskViewModel.markInProgress(task.id) { success ->
|
onNavigateToEditTask(task)
|
||||||
if (success) {
|
},
|
||||||
residenceViewModel.loadResidenceTasks(residenceId)
|
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 = {
|
||||||
FloatingActionButton(
|
// Only show FAB when there are properties
|
||||||
onClick = onAddResidence,
|
val hasResidences = myResidencesState is ApiResult.Success &&
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
if (hasResidences) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Property")
|
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 ->
|
) { paddingValues ->
|
||||||
when (myResidencesState) {
|
when (myResidencesState) {
|
||||||
is ApiResult.Idle, is ApiResult.Loading -> {
|
is ApiResult.Idle, is ApiResult.Loading -> {
|
||||||
@@ -119,7 +135,8 @@ fun ResidencesScreen(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.padding(24.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Home,
|
Icons.Default.Home,
|
||||||
@@ -137,6 +154,26 @@ fun ResidencesScreen(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.mycrib.shared.network.ApiResult
|
|||||||
@Composable
|
@Composable
|
||||||
fun TasksScreen(
|
fun TasksScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
|
onAddTask: () -> Unit = {},
|
||||||
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
||||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
|
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
|
||||||
) {
|
) {
|
||||||
@@ -55,14 +56,10 @@ fun TasksScreen(
|
|||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
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 ->
|
) { paddingValues ->
|
||||||
when (tasksState) {
|
when (tasksState) {
|
||||||
is ApiResult.Idle, is ApiResult.Loading -> {
|
is ApiResult.Idle, is ApiResult.Loading -> {
|
||||||
@@ -107,7 +104,33 @@ fun TasksScreen(
|
|||||||
.padding(paddingValues),
|
.padding(paddingValues),
|
||||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
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 {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|||||||
@@ -44,10 +44,13 @@ fun VerifyEmailScreen(
|
|||||||
errorMessage = (verifyState as ApiResult.Error).message
|
errorMessage = (verifyState as ApiResult.Error).message
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
is ApiResult.Idle, is ApiResult.Loading -> {
|
is ApiResult.Loading -> {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
}
|
}
|
||||||
|
is ApiResult.Idle -> {
|
||||||
|
// Do nothing - initial state, no loading indicator needed
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,88 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
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(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Color(0xFF6200EE),
|
primary = DarkBlue,
|
||||||
secondary = Color(0xFF03DAC6),
|
onPrimary = Color.White,
|
||||||
tertiary = Color(0xFF3700B3)
|
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(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Color(0xFF6200EE),
|
primary = BrightBlue,
|
||||||
secondary = Color(0xFF03DAC6),
|
onPrimary = Color.White,
|
||||||
tertiary = Color(0xFF3700B3)
|
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
|
@Composable
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ class TaskViewModel : ViewModel() {
|
|||||||
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
||||||
|
|
||||||
fun loadTasks() {
|
fun loadTasks() {
|
||||||
|
println("TaskViewModel: loadTasks called")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_tasksState.value = ApiResult.Loading
|
_tasksState.value = ApiResult.Loading
|
||||||
val token = TokenStorage.getToken()
|
val token = TokenStorage.getToken()
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
_tasksState.value = taskApi.getTasks(token)
|
val result = taskApi.getTasks(token)
|
||||||
|
println("TaskViewModel: loadTasks result: $result")
|
||||||
|
_tasksState.value = result
|
||||||
} else {
|
} else {
|
||||||
_tasksState.value = ApiResult.Error("Not authenticated", 401)
|
_tasksState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
}
|
}
|
||||||
@@ -50,11 +53,17 @@ class TaskViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createNewTask(request: TaskCreateRequest) {
|
fun createNewTask(request: TaskCreateRequest) {
|
||||||
|
println("TaskViewModel: createNewTask called with $request")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
println("TaskViewModel: Setting state to Loading")
|
||||||
_taskAddNewCustomTaskState.value = ApiResult.Loading
|
_taskAddNewCustomTaskState.value = ApiResult.Loading
|
||||||
try {
|
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) {
|
} catch (e: Exception) {
|
||||||
|
println("TaskViewModel: Exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
_taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
|
_taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ struct ResidenceDetailView: View {
|
|||||||
@State private var showEditResidence = false
|
@State private var showEditResidence = false
|
||||||
@State private var showEditTask = false
|
@State private var showEditTask = false
|
||||||
@State private var selectedTaskForEdit: TaskDetail?
|
@State private var selectedTaskForEdit: TaskDetail?
|
||||||
@State private var showInProgressTasks = false
|
|
||||||
@State private var showDoneTasks = false
|
|
||||||
@State private var selectedTaskForComplete: TaskDetail?
|
@State private var selectedTaskForComplete: TaskDetail?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -39,8 +37,6 @@ struct ResidenceDetailView: View {
|
|||||||
if let tasksResponse = tasksResponse {
|
if let tasksResponse = tasksResponse {
|
||||||
TasksSection(
|
TasksSection(
|
||||||
tasksResponse: tasksResponse,
|
tasksResponse: tasksResponse,
|
||||||
showInProgressTasks: $showInProgressTasks,
|
|
||||||
showDoneTasks: $showDoneTasks,
|
|
||||||
onEditTask: { task in
|
onEditTask: { task in
|
||||||
selectedTaskForEdit = task
|
selectedTaskForEdit = task
|
||||||
showEditTask = true
|
showEditTask = true
|
||||||
|
|||||||
@@ -145,12 +145,13 @@ struct TaskCard: View {
|
|||||||
description: "Remove all debris from gutters",
|
description: "Remove all debris from gutters",
|
||||||
category: TaskCategory(id: 1, name: "maintenance", description: ""),
|
category: TaskCategory(id: 1, name: "maintenance", description: ""),
|
||||||
priority: TaskPriority(id: 2, name: "medium", displayName: "", 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: ""),
|
status: TaskStatus(id: 1, name: "pending", displayName: "", description: ""),
|
||||||
dueDate: "2024-12-15",
|
dueDate: "2024-12-15",
|
||||||
estimatedCost: "150.00",
|
estimatedCost: "150.00",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
notes: nil,
|
notes: nil,
|
||||||
|
archived: false,
|
||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
updatedAt: "2024-01-01T00:00:00Z",
|
updatedAt: "2024-01-01T00:00:00Z",
|
||||||
nextScheduledDate: nil,
|
nextScheduledDate: nil,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import ComposeApp
|
|||||||
|
|
||||||
struct TasksSection: View {
|
struct TasksSection: View {
|
||||||
let tasksResponse: TasksByResidenceResponse
|
let tasksResponse: TasksByResidenceResponse
|
||||||
@Binding var showInProgressTasks: Bool
|
|
||||||
@Binding var showDoneTasks: Bool
|
|
||||||
let onEditTask: (TaskDetail) -> Void
|
let onEditTask: (TaskDetail) -> Void
|
||||||
let onCancelTask: (TaskDetail) -> Void
|
let onCancelTask: (TaskDetail) -> Void
|
||||||
let onUncancelTask: (TaskDetail) -> Void
|
let onUncancelTask: (TaskDetail) -> Void
|
||||||
@@ -13,104 +11,82 @@ struct TasksSection: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
Text("Tasks")
|
||||||
Text("Tasks")
|
.font(.title2)
|
||||||
.font(.title2)
|
.fontWeight(.bold)
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
Spacer()
|
if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty && tasksResponse.archivedTasks.isEmpty {
|
||||||
|
|
||||||
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 {
|
|
||||||
EmptyTasksView()
|
EmptyTasksView()
|
||||||
} else {
|
} else {
|
||||||
// Upcoming tasks
|
GeometryReader { geometry in
|
||||||
ForEach(tasksResponse.upcomingTasks, id: \.id) { task in
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
TaskCard(
|
LazyHStack(spacing: 16) {
|
||||||
task: task,
|
// Upcoming Column
|
||||||
onEdit: { onEditTask(task) },
|
TaskColumnView(
|
||||||
onCancel: { onCancelTask(task) },
|
title: "Upcoming",
|
||||||
onUncancel: nil,
|
icon: "calendar",
|
||||||
onMarkInProgress: { onMarkInProgress(task) },
|
color: .blue,
|
||||||
onComplete: { onCompleteTask(task) }
|
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
|
// In Progress Column
|
||||||
if !tasksResponse.inProgressTasks.isEmpty {
|
TaskColumnView(
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
title: "In Progress",
|
||||||
HStack {
|
icon: "play.circle",
|
||||||
Label("In Progress (\(tasksResponse.inProgressTasks.count))", systemImage: "play.circle")
|
color: .orange,
|
||||||
.font(.headline)
|
count: tasksResponse.inProgressTasks.count,
|
||||||
.foregroundColor(.orange)
|
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")
|
// Archived Column
|
||||||
.foregroundColor(.secondary)
|
TaskColumnView(
|
||||||
.font(.caption)
|
title: "Archived",
|
||||||
}
|
icon: "archivebox",
|
||||||
.padding(.top, 8)
|
color: .gray,
|
||||||
.contentShape(Rectangle())
|
count: tasksResponse.archivedTasks.count,
|
||||||
.onTapGesture {
|
tasks: tasksResponse.archivedTasks,
|
||||||
showInProgressTasks.toggle()
|
onEditTask: onEditTask,
|
||||||
}
|
onCancelTask: nil,
|
||||||
|
onUncancelTask: nil,
|
||||||
if showInProgressTasks {
|
onMarkInProgress: nil,
|
||||||
ForEach(tasksResponse.inProgressTasks, id: \.id) { task in
|
onCompleteTask: nil
|
||||||
TaskCard(
|
)
|
||||||
task: task,
|
.frame(width: geometry.size.width - 48)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.scrollTargetLayout()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
.scrollTargetBehavior(.viewAligned)
|
||||||
}
|
}
|
||||||
|
.frame(height: 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,12 +110,13 @@ struct TasksSection: View {
|
|||||||
description: "Remove all debris",
|
description: "Remove all debris",
|
||||||
category: TaskCategory(id: 1, name: "maintenance", description: "General upkeep tasks"),
|
category: TaskCategory(id: 1, name: "maintenance", description: "General upkeep tasks"),
|
||||||
priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: "Standard priority"),
|
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"),
|
status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: "Awaiting completion"),
|
||||||
dueDate: "2024-12-15",
|
dueDate: "2024-12-15",
|
||||||
estimatedCost: "150.00",
|
estimatedCost: "150.00",
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
notes: nil,
|
notes: nil,
|
||||||
|
archived: false,
|
||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
updatedAt: "2024-01-01T00:00:00Z",
|
updatedAt: "2024-01-01T00:00:00Z",
|
||||||
nextScheduledDate: nil,
|
nextScheduledDate: nil,
|
||||||
@@ -156,22 +133,22 @@ struct TasksSection: View {
|
|||||||
description: "Kitchen sink fixed",
|
description: "Kitchen sink fixed",
|
||||||
category: TaskCategory(id: 2, name: "plumbing", description: "Plumbing tasks"),
|
category: TaskCategory(id: 2, name: "plumbing", description: "Plumbing tasks"),
|
||||||
priority: TaskPriority(id: 3, name: "high", displayName: "High", description: "High priority"),
|
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"),
|
status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: "Task completed"),
|
||||||
dueDate: "2024-11-01",
|
dueDate: "2024-11-01",
|
||||||
estimatedCost: "200.00",
|
estimatedCost: "200.00",
|
||||||
actualCost: "185.00",
|
actualCost: nil,
|
||||||
notes: nil,
|
notes: nil,
|
||||||
|
archived: false,
|
||||||
createdAt: "2024-10-01T00:00:00Z",
|
createdAt: "2024-10-01T00:00:00Z",
|
||||||
updatedAt: "2024-11-05T00:00:00Z",
|
updatedAt: "2024-11-05T00:00:00Z",
|
||||||
nextScheduledDate: nil,
|
nextScheduledDate: nil,
|
||||||
showCompletedButton: false,
|
showCompletedButton: false,
|
||||||
completions: []
|
completions: []
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
|
archivedTasks: []
|
||||||
),
|
),
|
||||||
showInProgressTasks: .constant(true),
|
|
||||||
showDoneTasks: .constant(true),
|
|
||||||
onEditTask: { _ in },
|
onEditTask: { _ in },
|
||||||
onCancelTask: { _ in },
|
onCancelTask: { _ in },
|
||||||
onUncancelTask: { _ in },
|
onUncancelTask: { _ in },
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ struct AddTaskView: View {
|
|||||||
frequency: Int32(frequency.id),
|
frequency: Int32(frequency.id),
|
||||||
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
|
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
|
||||||
priority: Int32(priority.id),
|
priority: Int32(priority.id),
|
||||||
status: Int32(status.id),
|
|
||||||
dueDate: dueDateString,
|
dueDate: dueDateString,
|
||||||
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
|
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,16 +3,27 @@ import ComposeApp
|
|||||||
|
|
||||||
struct AllTasksView: View {
|
struct AllTasksView: View {
|
||||||
@StateObject private var taskViewModel = TaskViewModel()
|
@StateObject private var taskViewModel = TaskViewModel()
|
||||||
|
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||||
@State private var tasksResponse: AllTasksResponse?
|
@State private var tasksResponse: AllTasksResponse?
|
||||||
@State private var isLoadingTasks = false
|
@State private var isLoadingTasks = false
|
||||||
@State private var tasksError: String?
|
@State private var tasksError: String?
|
||||||
@State private var showAddTask = false
|
@State private var showAddTask = false
|
||||||
@State private var showEditTask = false
|
@State private var showEditTask = false
|
||||||
@State private var selectedTaskForEdit: TaskDetail?
|
@State private var selectedTaskForEdit: TaskDetail?
|
||||||
@State private var showInProgressTasks = false
|
|
||||||
@State private var showDoneTasks = false
|
|
||||||
@State private var selectedTaskForComplete: TaskDetail?
|
@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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(.systemGroupedBackground)
|
Color(.systemGroupedBackground)
|
||||||
@@ -25,68 +36,179 @@ struct AllTasksView: View {
|
|||||||
loadAllTasks()
|
loadAllTasks()
|
||||||
}
|
}
|
||||||
} else if let tasksResponse = tasksResponse {
|
} else if let tasksResponse = tasksResponse {
|
||||||
ScrollView {
|
if hasNoTasks {
|
||||||
VStack(spacing: 16) {
|
// Empty state with big button
|
||||||
// Header Card
|
VStack(spacing: 24) {
|
||||||
VStack(spacing: 12) {
|
Spacer()
|
||||||
Image(systemName: "checklist")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundStyle(.blue.gradient)
|
|
||||||
|
|
||||||
Text("All Tasks")
|
Image(systemName: "checklist")
|
||||||
.font(.title)
|
.font(.system(size: 64))
|
||||||
.fontWeight(.bold)
|
.foregroundStyle(.blue.opacity(0.6))
|
||||||
|
|
||||||
Text("Tasks across all your properties")
|
Text("No tasks yet")
|
||||||
.font(.subheadline)
|
.font(.title2)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.semibold)
|
||||||
}
|
|
||||||
.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
|
Text("Create your first task to get started")
|
||||||
AllTasksSectionView(
|
.font(.body)
|
||||||
tasksResponse: tasksResponse,
|
.foregroundColor(.secondary)
|
||||||
showInProgressTasks: $showInProgressTasks,
|
.multilineTextAlignment(.center)
|
||||||
showDoneTasks: $showDoneTasks,
|
|
||||||
onEditTask: { task in
|
Button(action: {
|
||||||
selectedTaskForEdit = task
|
showAddTask = true
|
||||||
showEditTask = true
|
}) {
|
||||||
},
|
HStack(spacing: 8) {
|
||||||
onCancelTask: { task in
|
Image(systemName: "plus")
|
||||||
taskViewModel.cancelTask(id: task.id) { _ in
|
Text("Add Task")
|
||||||
loadAllTasks()
|
.fontWeight(.semibold)
|
||||||
}
|
|
||||||
},
|
|
||||||
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(maxWidth: .infinity)
|
||||||
.padding(.horizontal)
|
.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")
|
.navigationTitle("All Tasks")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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) {
|
.sheet(isPresented: $showEditTask) {
|
||||||
if let task = selectedTaskForEdit {
|
if let task = selectedTaskForEdit {
|
||||||
EditTaskView(task: task, isPresented: $showEditTask)
|
EditTaskView(task: task, isPresented: $showEditTask)
|
||||||
@@ -98,6 +220,11 @@ struct AllTasksView: View {
|
|||||||
loadAllTasks()
|
loadAllTasks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showAddTask) { isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
loadAllTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: showEditTask) { isShowing in
|
.onChange(of: showEditTask) { isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
loadAllTasks()
|
loadAllTasks()
|
||||||
@@ -105,155 +232,118 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadAllTasks()
|
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() {
|
struct TaskColumnView: View {
|
||||||
guard let token = TokenStorage.shared.getToken() else { return }
|
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)?
|
||||||
|
|
||||||
isLoadingTasks = true
|
var body: some View {
|
||||||
tasksError = nil
|
VStack(spacing: 0) {
|
||||||
|
// Tasks List
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(color)
|
||||||
|
|
||||||
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
|
Text(title)
|
||||||
taskApi.getTasks(token: token, days: 30) { result, error in
|
.font(.headline)
|
||||||
if let successResult = result as? ApiResultSuccess<AllTasksResponse> {
|
.foregroundColor(color)
|
||||||
self.tasksResponse = successResult.data
|
|
||||||
self.isLoadingTasks = false
|
Spacer()
|
||||||
} else if let errorResult = result as? ApiResultError {
|
|
||||||
self.tasksError = errorResult.message
|
Text("\(count)")
|
||||||
self.isLoadingTasks = false
|
.font(.caption)
|
||||||
} else if let error = error {
|
.fontWeight(.semibold)
|
||||||
self.tasksError = error.localizedDescription
|
.foregroundColor(.white)
|
||||||
self.isLoadingTasks = false
|
.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 {
|
// Extension to apply corner radius to specific corners
|
||||||
let tasksResponse: AllTasksResponse
|
extension View {
|
||||||
@Binding var showInProgressTasks: Bool
|
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||||
@Binding var showDoneTasks: Bool
|
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||||
let onEditTask: (TaskDetail) -> Void
|
}
|
||||||
let onCancelTask: (TaskDetail) -> Void
|
}
|
||||||
let onUncancelTask: (TaskDetail) -> Void
|
|
||||||
let onMarkInProgress: (TaskDetail) -> Void
|
|
||||||
let onCompleteTask: (TaskDetail) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
struct RoundedCorner: Shape {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
var radius: CGFloat = .infinity
|
||||||
// Task summary pills
|
var corners: UIRectCorner = .allCorners
|
||||||
HStack(spacing: 8) {
|
|
||||||
TaskPill(
|
|
||||||
count: Int32(tasksResponse.summary.upcoming),
|
|
||||||
label: "Upcoming",
|
|
||||||
color: .blue
|
|
||||||
)
|
|
||||||
|
|
||||||
TaskPill(
|
func path(in rect: CGRect) -> Path {
|
||||||
count: Int32(tasksResponse.summary.inProgress),
|
let path = UIBezierPath(
|
||||||
label: "In Progress",
|
roundedRect: rect,
|
||||||
color: .orange
|
byRoundingCorners: corners,
|
||||||
)
|
cornerRadii: CGSize(width: radius, height: radius)
|
||||||
|
)
|
||||||
TaskPill(
|
return Path(path.cgPath)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,3 +352,37 @@ struct AllTasksSectionView: View {
|
|||||||
AllTasksView()
|
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,
|
frequency: frequency.id,
|
||||||
intervalDays: nil,
|
intervalDays: nil,
|
||||||
priority: priority.id,
|
priority: priority.id,
|
||||||
status: status.id,
|
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
|
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user