diff --git a/ENVIRONMENT_SETUP.md b/ENVIRONMENT_SETUP.md new file mode 100644 index 0000000..4b011c3 --- /dev/null +++ b/ENVIRONMENT_SETUP.md @@ -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! diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..b226b46 --- /dev/null +++ b/QUICK_START.md @@ -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. ✨ diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index cdf2572..95bb377 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -3,11 +3,13 @@ package com.example.mycrib import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -32,9 +34,18 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.mycrib.android.ui.screens.MainScreen +import com.mycrib.android.ui.screens.ProfileScreen +import com.mycrib.android.ui.theme.MyCribTheme import com.mycrib.navigation.* import com.mycrib.repository.LookupsRepository import com.mycrib.shared.models.Residence +import com.mycrib.shared.models.TaskCategory +import com.mycrib.shared.models.TaskDetail +import com.mycrib.shared.models.TaskFrequency +import com.mycrib.shared.models.TaskPriority +import com.mycrib.shared.models.TaskStatus +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.AuthApi import com.mycrib.storage.TokenStorage import mycrib.composeapp.generated.resources.Res @@ -55,12 +66,12 @@ fun App() { if (hasToken) { // Fetch current user to check verification status - val authApi = com.mycrib.shared.network.AuthApi() + val authApi = AuthApi() val token = TokenStorage.getToken() if (token != null) { when (val result = authApi.getCurrentUser(token)) { - is com.mycrib.shared.network.ApiResult.Success -> { + is ApiResult.Success -> { isVerified = result.data.verified LookupsRepository.initialize() } @@ -76,33 +87,34 @@ fun App() { isCheckingAuth = false } - if (isCheckingAuth) { - // Show loading screen while checking auth + MyCribTheme { + if (isCheckingAuth) { + // Show loading screen while checking auth + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + return@MyCribTheme + } + + val startDestination = when { + !isLoggedIn -> LoginRoute + !isVerified -> VerifyEmailRoute + else -> MainRoute + } + Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - androidx.compose.material3.CircularProgressIndicator() - } - } - return - } - - val startDestination = when { - !isLoggedIn -> LoginRoute - !isVerified -> VerifyEmailRoute - else -> MainRoute - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - NavHost( + NavHost( navController = navController, startDestination = startDestination ) { @@ -187,6 +199,11 @@ fun App() { onAddResidence = { navController.navigate(AddResidenceRoute) }, + onAddTask = { + // Tasks are added from within a residence + // Navigate to first residence or show message if no residences exist + // For now, this will be handled by the UI showing "add a property first" + }, onNavigateToEditResidence = { residence -> navController.navigate( EditResidenceRoute( @@ -399,16 +416,20 @@ fun App() { composable { backStackEntry -> val route = backStackEntry.toRoute() EditTaskScreen( - task = com.mycrib.shared.models.TaskDetail( + task = TaskDetail( id = route.taskId, residence = route.residenceId, title = route.title, description = route.description, - category = com.mycrib.shared.models.TaskCategory(route.categoryId, route.categoryName), - frequency = com.mycrib.shared.models.TaskFrequency(route.frequencyId, route.frequencyName, ""), - priority = com.mycrib.shared.models.TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""), + category = TaskCategory(route.categoryId, route.categoryName), + frequency = TaskFrequency( + route.frequencyId, route.frequencyName, "", + daySpan = 0, + notifyDays = 0 + ), + priority = TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""), status = route.statusId?.let { - com.mycrib.shared.models.TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "") + TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "") }, dueDate = route.dueDate, estimatedCost = route.estimatedCost, @@ -426,7 +447,7 @@ fun App() { } composable { - com.mycrib.android.ui.screens.ProfileScreen( + ProfileScreen( onNavigateBack = { navController.popBackStack() }, @@ -443,6 +464,7 @@ fun App() { ) } } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index fe8c73d..016b8fe 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -13,11 +13,12 @@ data class CustomTask ( val description: String? = null, val category: String, val priority: String, - val status: String, + val status: String? = null, @SerialName("due_date") val dueDate: String, @SerialName("estimated_cost") val estimatedCost: String? = null, @SerialName("actual_cost") val actualCost: String? = null, val notes: String? = null, + val archived: Boolean = false, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("show_completed_button") val showCompletedButton: Boolean = false, @@ -43,7 +44,6 @@ data class TaskCreateRequest( val frequency: Int, @SerialName("interval_days") val intervalDays: Int? = null, val priority: Int, - val status: Int, @SerialName("due_date") val dueDate: String, @SerialName("estimated_cost") val estimatedCost: String? = null ) @@ -64,6 +64,7 @@ data class TaskDetail( @SerialName("estimated_cost") val estimatedCost: String? = null, @SerialName("actual_cost") val actualCost: String? = null, val notes: String? = null, + val archived: Boolean = false, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("next_scheduled_date") val nextScheduledDate: String? = null, @@ -78,14 +79,16 @@ data class TasksByResidenceResponse( val summary: CategorizedTaskSummary, @SerialName("upcoming_tasks") val upcomingTasks: List, @SerialName("in_progress_tasks") val inProgressTasks: List, - @SerialName("done_tasks") val doneTasks: List + @SerialName("done_tasks") val doneTasks: List, + @SerialName("archived_tasks") val archivedTasks: List ) @Serializable data class CategorizedTaskSummary( val upcoming: Int, @SerialName("in_progress") val inProgress: Int, - val done: Int + val done: Int, + val archived: Int ) @Serializable @@ -94,7 +97,8 @@ data class AllTasksResponse( val summary: CategorizedTaskSummary, @SerialName("upcoming_tasks") val upcomingTasks: List, @SerialName("in_progress_tasks") val inProgressTasks: List, - @SerialName("done_tasks") val doneTasks: List + @SerialName("done_tasks") val doneTasks: List, + @SerialName("archived_tasks") val archivedTasks: List ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt index 8118dde..000b29b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt @@ -27,6 +27,8 @@ data class TaskFrequency( val id: Int, val name: String, @SerialName("display_name") val displayName: String, + @SerialName("day_span") val daySpan: Int? = null, + @SerialName("notify_days") val notifyDays: Int? = null ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt index fefc5f4..abda88e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt @@ -10,9 +10,20 @@ expect fun getLocalhostAddress(): String expect fun createHttpClient(): HttpClient object ApiClient { - private val BASE_URL = "http://${getLocalhostAddress()}:8000/api" - val httpClient = createHttpClient() - fun getBaseUrl() = BASE_URL + /** + * Get the current base URL based on environment configuration. + * To change environment, update ApiConfig.CURRENT_ENV + */ + fun getBaseUrl(): String = ApiConfig.getBaseUrl() + + /** + * Print current environment configuration + */ + init { + println("🌐 API Client initialized") + println("📍 Environment: ${ApiConfig.getEnvironmentName()}") + println("🔗 Base URL: ${getBaseUrl()}") + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt new file mode 100644 index 0000000..48723bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt @@ -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)" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt index 74fe4ca..a749adb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt @@ -30,7 +30,11 @@ fun AddNewTaskDialog( var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) } - var frequency by remember { mutableStateOf(TaskFrequency(id = 0, name = "", displayName = "")) } + var frequency by remember { mutableStateOf(TaskFrequency( + id = 0, name = "", displayName = "", + daySpan = 0, + notifyDays = 0 + )) } var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) } var showFrequencyDropdown by remember { mutableStateOf(false) } @@ -270,8 +274,7 @@ fun AddNewTaskDialog( intervalDays = intervalDays.toIntOrNull(), priority = priority.id, dueDate = dueDate, - estimatedCost = estimatedCost.ifBlank { null }, - status = 9 + estimatedCost = estimatedCost.ifBlank { null } ) ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt new file mode 100644 index 0000000..eded1da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index 1b7c687..2849c8c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -372,7 +372,11 @@ fun TaskCardPreview() { description = "Remove all debris from gutters and downspouts", category = TaskCategory(id = 1, name = "maintenance", description = ""), priority = TaskPriority(id = 2, name = "medium", displayName = "Medium", description = ""), - frequency = TaskFrequency(id = 1, name = "monthly", displayName = "Monthly"), + frequency = TaskFrequency( + id = 1, name = "monthly", displayName = "Monthly", + daySpan = 0, + notifyDays = 0 + ), status = TaskStatus(id = 1, name = "pending", displayName = "Pending", description = ""), dueDate = "2024-12-15", estimatedCost = "150.00", diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt new file mode 100644 index 0000000..727500a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskKanbanView.kt @@ -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, + inProgressTasks: List, + doneTasks: List, + archivedTasks: List, + 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, + 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 + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt index e844c23..86d307e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AllTasksScreen.kt @@ -12,8 +12,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.AddNewTaskWithResidenceDialog import com.mycrib.android.ui.components.CompleteTaskDialog import com.mycrib.android.ui.components.task.TaskCard +import com.mycrib.android.ui.components.task.TaskKanbanView +import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskViewModel import com.mycrib.shared.models.TaskDetail @@ -23,18 +26,23 @@ import com.mycrib.shared.network.ApiResult @Composable fun AllTasksScreen( onNavigateToEditTask: (TaskDetail) -> Unit, + onAddTask: () -> Unit = {}, viewModel: TaskViewModel = viewModel { TaskViewModel() }, - taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() } + taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }, + residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } ) { val tasksState by viewModel.tasksState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState() - var showInProgressTasks by remember { mutableStateOf(false) } - var showDoneTasks by remember { mutableStateOf(false) } + val myResidencesState by residenceViewModel.myResidencesState.collectAsState() + val createTaskState by viewModel.taskAddNewCustomTaskState.collectAsState() + var showCompleteDialog by remember { mutableStateOf(false) } + var showNewTaskDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.loadTasks() + residenceViewModel.loadMyResidences() } // Handle completion success @@ -50,6 +58,28 @@ fun AllTasksScreen( } } + // Handle task creation success + LaunchedEffect(createTaskState) { + println("AllTasksScreen: createTaskState changed to $createTaskState") + when (createTaskState) { + is ApiResult.Success -> { + println("AllTasksScreen: Task created successfully, closing dialog and reloading tasks") + showNewTaskDialog = false + viewModel.resetAddTaskState() + viewModel.loadTasks() + } + is ApiResult.Error -> { + println("AllTasksScreen: Task creation error: ${(createTaskState as ApiResult.Error).message}") + } + is ApiResult.Loading -> { + println("AllTasksScreen: Task creation loading") + } + else -> { + println("AllTasksScreen: Task creation idle") + } + } + } + Scaffold( topBar = { TopAppBar( @@ -59,6 +89,18 @@ fun AllTasksScreen( fontWeight = FontWeight.Bold ) }, + actions = { + IconButton( + onClick = { showNewTaskDialog = true }, + enabled = myResidencesState is ApiResult.Success && + (myResidencesState as ApiResult.Success).data.residences.isNotEmpty() + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add Task" + ) + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface ) @@ -109,7 +151,8 @@ fun AllTasksScreen( val taskData = (tasksState as ApiResult.Success).data val hasNoTasks = taskData.upcomingTasks.isEmpty() && taskData.inProgressTasks.isEmpty() && - taskData.doneTasks.isEmpty() + taskData.doneTasks.isEmpty() && + taskData.archivedTasks.isEmpty() if (hasNoTasks) { Box( @@ -120,211 +163,92 @@ fun AllTasksScreen( ) { Column( horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) ) { Icon( - Icons.Default.CheckCircle, + Icons.Default.Assignment, contentDescription = null, - modifier = Modifier.size(64.dp), + modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) ) Text( "No tasks yet", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold ) Text( - "Add a task to a residence to get started", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + "Create your first task to get started", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - } - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, - top = 16.dp, - bottom = 96.dp - ), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Task summary pills - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { showNewTaskDialog = true }, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(56.dp), + enabled = myResidencesState is ApiResult.Success && + (myResidencesState as ApiResult.Success).data.residences.isNotEmpty() ) { - TaskSummaryPill( - count = taskData.summary.upcoming, - label = "Upcoming", - color = MaterialTheme.colorScheme.primary - ) - TaskSummaryPill( - count = taskData.summary.inProgress, - label = "In Progress", - color = MaterialTheme.colorScheme.tertiary - ) - TaskSummaryPill( - count = taskData.summary.done, - label = "Done", - color = MaterialTheme.colorScheme.secondary - ) - } - } - - // Upcoming tasks header - if (taskData.upcomingTasks.isNotEmpty()) { - item { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically ) { - Icon( - Icons.Default.CalendarToday, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) + Icon(Icons.Default.Add, contentDescription = null) Text( - text = "Upcoming (${taskData.upcomingTasks.size})", + "Add Task", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp) + fontWeight = FontWeight.Bold ) } } + if (myResidencesState is ApiResult.Success && + (myResidencesState as ApiResult.Success).data.residences.isEmpty()) { + Text( + "Add a property first from the Residences tab", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } } - - // Upcoming tasks - items(taskData.upcomingTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { - selectedTask = task - showCompleteDialog = true - }, - onEditClick = { onNavigateToEditTask(task) }, - onCancelClick = { /* TODO */ }, - onUncancelClick = null, - onMarkInProgressClick = { - viewModel.markInProgress(task.id) { success -> - if (success) { - viewModel.loadTasks() - } - } - } - ) - } - - // In Progress section (collapsible) - if (taskData.inProgressTasks.isNotEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { showInProgressTasks = !showInProgressTasks } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary - ) - Text( - text = "In Progress (${taskData.inProgressTasks.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - Icon( - if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (showInProgressTasks) "Collapse" else "Expand" - ) + } + } else { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + TaskKanbanView( + upcomingTasks = taskData.upcomingTasks, + inProgressTasks = taskData.inProgressTasks, + doneTasks = taskData.doneTasks, + archivedTasks = taskData.archivedTasks, + onCompleteTask = { task -> + selectedTask = task + showCompleteDialog = true + }, + onEditTask = { task -> + onNavigateToEditTask(task) + }, + onCancelTask = { task -> +// viewModel.cancelTask(task.id) { _ -> +// viewModel.loadTasks() +// } + }, + onUncancelTask = { task -> +// viewModel.uncancelTask(task.id) { _ -> +// viewModel.loadTasks() +// } + }, + onMarkInProgress = { task -> + viewModel.markInProgress(task.id) { success -> + if (success) { + viewModel.loadTasks() } } } - - if (showInProgressTasks) { - items(taskData.inProgressTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { - selectedTask = task - showCompleteDialog = true - }, - onEditClick = { onNavigateToEditTask(task) }, - onCancelClick = { /* TODO */ }, - onUncancelClick = null, - onMarkInProgressClick = null - ) - } - } - } - - // Done section (collapsible) - if (taskData.doneTasks.isNotEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { showDoneTasks = !showDoneTasks } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary - ) - Text( - text = "Done (${taskData.doneTasks.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - Icon( - if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = if (showDoneTasks) "Collapse" else "Expand" - ) - } - } - } - - if (showDoneTasks) { - items(taskData.doneTasks) { task -> - TaskCard( - task = task, - onCompleteClick = null, - onEditClick = { onNavigateToEditTask(task) }, - onCancelClick = null, - onUncancelClick = null, - onMarkInProgressClick = null - ) - } - } - } + ) } } } @@ -355,34 +279,22 @@ fun AllTasksScreen( } ) } -} -@Composable -private fun TaskSummaryPill( - count: Int, - label: String, - color: androidx.compose.ui.graphics.Color -) { - Surface( - color = color.copy(alpha = 0.1f), - shape = MaterialTheme.shapes.small - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Text( - text = count.toString(), - style = MaterialTheme.typography.labelLarge, - color = color, - fontWeight = FontWeight.Bold - ) - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = color - ) - } + if (showNewTaskDialog && myResidencesState is ApiResult.Success) { + AddNewTaskWithResidenceDialog( + residencesResponse = (myResidencesState as ApiResult.Success).data, + onDismiss = { + showNewTaskDialog = false + viewModel.resetAddTaskState() + }, + onCreate = { taskRequest -> + println("AllTasksScreen: onCreate called with request: $taskRequest") + viewModel.createNewTask(taskRequest) + }, + isLoading = createTaskState is ApiResult.Loading, + errorMessage = if (createTaskState is ApiResult.Error) { + (createTaskState as ApiResult.Error).message + } else null + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt index 515bb68..9e1205f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt @@ -300,7 +300,6 @@ fun EditTaskScreen( category = selectedCategory!!.id, frequency = selectedFrequency!!.id, priority = selectedPriority!!.id, - status = selectedStatus!!.id, dueDate = dueDate, estimatedCost = estimatedCost.ifBlank { null } ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt index 0b96827..f39e836 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt @@ -22,7 +22,8 @@ fun MainScreen( onResidenceClick: (Int) -> Unit, onAddResidence: () -> Unit, onNavigateToEditResidence: (Residence) -> Unit, - onNavigateToEditTask: (com.mycrib.shared.models.TaskDetail) -> Unit + onNavigateToEditTask: (com.mycrib.shared.models.TaskDetail) -> Unit, + onAddTask: () -> Unit ) { var selectedTab by remember { mutableStateOf(0) } val navController = rememberNavController() @@ -112,7 +113,8 @@ fun MainScreen( composable { Box(modifier = Modifier.fillMaxSize()) { AllTasksScreen( - onNavigateToEditTask = onNavigateToEditTask + onNavigateToEditTask = onNavigateToEditTask, + onAddTask = onAddTask ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index 5921288..433f41c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -20,6 +20,7 @@ import com.mycrib.android.ui.components.common.InfoCard import com.mycrib.android.ui.components.residence.PropertyDetailItem import com.mycrib.android.ui.components.residence.DetailRow import com.mycrib.android.ui.components.task.TaskCard +import com.mycrib.android.ui.components.task.TaskKanbanView import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel import com.mycrib.android.viewmodel.TaskViewModel @@ -48,8 +49,6 @@ fun ResidenceDetailScreen( var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } var showNewTaskDialog by remember { mutableStateOf(false) } - var showInProgressTasks by remember { mutableStateOf(false) } - var showDoneTasks by remember { mutableStateOf(false) } LaunchedEffect(residenceId) { residenceViewModel.getResidence(residenceId) { result -> @@ -394,7 +393,7 @@ fun ResidenceDetailScreen( } is ApiResult.Success -> { val taskData = (tasksState as ApiResult.Success).data - if (taskData.upcomingTasks.isEmpty() && taskData.inProgressTasks.isEmpty() && taskData.doneTasks.isEmpty()) { + if (taskData.upcomingTasks.isEmpty() && taskData.inProgressTasks.isEmpty() && taskData.doneTasks.isEmpty() && taskData.archivedTasks.isEmpty()) { item { Card( modifier = Modifier.fillMaxWidth(), @@ -427,137 +426,38 @@ fun ResidenceDetailScreen( } } } else { - // Upcoming tasks section - items(taskData.upcomingTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { - selectedTask = task - showCompleteDialog = true - }, - onEditClick = { - onNavigateToEditTask(task) - }, - onCancelClick = { - residenceViewModel.cancelTask(task.id) - }, - onUncancelClick = null, - onMarkInProgressClick = { - taskViewModel.markInProgress(task.id) { success -> - if (success) { - residenceViewModel.loadResidenceTasks(residenceId) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(500.dp) + ) { + TaskKanbanView( + upcomingTasks = taskData.upcomingTasks, + inProgressTasks = taskData.inProgressTasks, + doneTasks = taskData.doneTasks, + archivedTasks = taskData.archivedTasks, + onCompleteTask = { task -> + selectedTask = task + showCompleteDialog = true + }, + onEditTask = { task -> + onNavigateToEditTask(task) + }, + onCancelTask = { task -> + residenceViewModel.cancelTask(task.id) + }, + onUncancelTask = { task -> + residenceViewModel.uncancelTask(task.id) + }, + onMarkInProgress = { task -> + taskViewModel.markInProgress(task.id) { success -> + if (success) { + residenceViewModel.loadResidenceTasks(residenceId) + } } } - } - ) - } - - // In Progress tasks section - if (taskData.inProgressTasks.isNotEmpty()) { - item { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showInProgressTasks = !showInProgressTasks } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.PlayCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "In Progress (${taskData.inProgressTasks.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.tertiary - ) - } - Icon( - if (showInProgressTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - if (showInProgressTasks) { - items(taskData.inProgressTasks) { task -> - TaskCard( - task = task, - onCompleteClick = { - selectedTask = task - showCompleteDialog = true - }, - onEditClick = { - onNavigateToEditTask(task) - }, - onCancelClick = { - residenceViewModel.cancelTask(task.id) - }, - onUncancelClick = null, - onMarkInProgressClick = null - ) - } - } - } - - // Done tasks section - if (taskData.doneTasks.isNotEmpty()) { - item { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showDoneTasks = !showDoneTasks } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Done (${taskData.doneTasks.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - Icon( - if (showDoneTasks) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - if (showDoneTasks) { - items(taskData.doneTasks) { task -> - TaskCard( - task = task, - onCompleteClick = null, - onEditClick = { - onNavigateToEditTask(task) - }, - onCancelClick = null, - onUncancelClick = { - residenceViewModel.uncancelTask(task.id) - }, - onMarkInProgressClick = null - ) - } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 9d360ef..076e28f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -57,14 +57,30 @@ fun ResidencesScreen( ) }, floatingActionButton = { - FloatingActionButton( - onClick = onAddResidence, - containerColor = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(16.dp) - ) { - Icon(Icons.Default.Add, contentDescription = "Add Property") + // Only show FAB when there are properties + val hasResidences = myResidencesState is ApiResult.Success && + (myResidencesState as ApiResult.Success).data.residences.isNotEmpty() + + if (hasResidences) { + Box(modifier = Modifier.padding(bottom = 80.dp)) { + FloatingActionButton( + onClick = onAddResidence, + containerColor = MaterialTheme.colorScheme.primary, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 8.dp, + pressedElevation = 12.dp + ) + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add Property", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } } - } + }, + floatingActionButtonPosition = FabPosition.End ) { paddingValues -> when (myResidencesState) { is ApiResult.Idle, is ApiResult.Loading -> { @@ -119,7 +135,8 @@ fun ResidencesScreen( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) ) { Icon( Icons.Default.Home, @@ -137,6 +154,26 @@ fun ResidencesScreen( style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onAddResidence, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(56.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, contentDescription = null) + Text( + "Add Property", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } } } } else { diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt index b2b6359..69bdd59 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt @@ -20,6 +20,7 @@ import com.mycrib.shared.network.ApiResult @Composable fun TasksScreen( onNavigateBack: () -> Unit, + onAddTask: () -> Unit = {}, viewModel: TaskViewModel = viewModel { TaskViewModel() }, taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() } ) { @@ -55,14 +56,10 @@ fun TasksScreen( IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } - }, - actions = { - IconButton(onClick = { /* TODO: Add task */ }) { - Icon(Icons.Default.Add, contentDescription = "Add") - } } ) - } + }, + // No FAB on Tasks screen - tasks are added from within residences ) { paddingValues -> when (tasksState) { is ApiResult.Idle, is ApiResult.Loading -> { @@ -107,7 +104,33 @@ fun TasksScreen( .padding(paddingValues), contentAlignment = androidx.compose.ui.Alignment.Center ) { - Text("No tasks yet. Add one to get started!") + Column( + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + Icon( + Icons.Default.Assignment, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + Text( + "No tasks yet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold + ) + Text( + "Tasks are created from your properties.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Go to Residences tab to add a property, then add tasks to it!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } } } else { LazyColumn( diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt index 364e4d5..ea54801 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt @@ -44,10 +44,13 @@ fun VerifyEmailScreen( errorMessage = (verifyState as ApiResult.Error).message isLoading = false } - is ApiResult.Idle, is ApiResult.Loading -> { + is ApiResult.Loading -> { isLoading = true errorMessage = "" } + is ApiResult.Idle -> { + // Do nothing - initial state, no loading indicator needed + } else -> {} } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/Theme.kt index 5e357e7b..ed8042b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/Theme.kt @@ -5,16 +5,88 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +// Bright color palette +private val BrightBlue = Color(0xFF007AFF) +private val BrightGreen = Color(0xFF34C759) +private val BrightOrange = Color(0xFFFF9500) +private val BrightRed = Color(0xFFFF3B30) +private val BrightPurple = Color(0xFFAF52DE) +private val BrightTeal = Color(0xFF5AC8FA) + +// Light variations for containers +private val LightBlue = Color(0xFFE3F2FD) +private val LightGreen = Color(0xFFE8F5E9) +private val LightOrange = Color(0xFFFFF3E0) + +// Dark variations +private val DarkBlue = Color(0xFF0A84FF) +private val DarkGreen = Color(0xFF30D158) +private val DarkOrange = Color(0xFFFF9F0A) + private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF6200EE), - secondary = Color(0xFF03DAC6), - tertiary = Color(0xFF3700B3) + primary = DarkBlue, + onPrimary = Color.White, + primaryContainer = Color(0xFF003D75), + onPrimaryContainer = Color(0xFFD0E4FF), + + secondary = DarkGreen, + onSecondary = Color.White, + secondaryContainer = Color(0xFF1B5E20), + onSecondaryContainer = Color(0xFFB9F6CA), + + tertiary = DarkOrange, + onTertiary = Color.White, + tertiaryContainer = Color(0xFF663C00), + onTertiaryContainer = Color(0xFFFFE0B2), + + error = BrightRed, + onError = Color.White, + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + + background = Color(0xFF1C1B1F), + onBackground = Color(0xFFE6E1E5), + + surface = Color(0xFF1C1B1F), + onSurface = Color(0xFFE6E1E5), + surfaceVariant = Color(0xFF49454F), + onSurfaceVariant = Color(0xFFCAC4D0), + + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF49454F) ) private val LightColorScheme = lightColorScheme( - primary = Color(0xFF6200EE), - secondary = Color(0xFF03DAC6), - tertiary = Color(0xFF3700B3) + primary = BrightBlue, + onPrimary = Color.White, + primaryContainer = LightBlue, + onPrimaryContainer = Color(0xFF001D35), + + secondary = BrightGreen, + onSecondary = Color.White, + secondaryContainer = LightGreen, + onSecondaryContainer = Color(0xFF002106), + + tertiary = BrightOrange, + onTertiary = Color.White, + tertiaryContainer = LightOrange, + onTertiaryContainer = Color(0xFF2B1700), + + error = BrightRed, + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + + background = Color(0xFFFFFBFE), + onBackground = Color(0xFF1C1B1F), + + surface = Color(0xFFFFFBFE), + onSurface = Color(0xFF1C1B1F), + surfaceVariant = Color(0xFFE7E0EC), + onSurfaceVariant = Color(0xFF49454F), + + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0) ) @Composable diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt index 2ca9467..31e4d5e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -26,11 +26,14 @@ class TaskViewModel : ViewModel() { val taskAddNewCustomTaskState: StateFlow> = _taskAddNewCustomTaskState fun loadTasks() { + println("TaskViewModel: loadTasks called") viewModelScope.launch { _tasksState.value = ApiResult.Loading val token = TokenStorage.getToken() if (token != null) { - _tasksState.value = taskApi.getTasks(token) + val result = taskApi.getTasks(token) + println("TaskViewModel: loadTasks result: $result") + _tasksState.value = result } else { _tasksState.value = ApiResult.Error("Not authenticated", 401) } @@ -50,11 +53,17 @@ class TaskViewModel : ViewModel() { } fun createNewTask(request: TaskCreateRequest) { + println("TaskViewModel: createNewTask called with $request") viewModelScope.launch { + println("TaskViewModel: Setting state to Loading") _taskAddNewCustomTaskState.value = ApiResult.Loading try { - _taskAddNewCustomTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request) + val result = taskApi.createTask(TokenStorage.getToken()!!, request) + println("TaskViewModel: API result: $result") + _taskAddNewCustomTaskState.value = result } catch (e: Exception) { + println("TaskViewModel: Exception: ${e.message}") + e.printStackTrace() _taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error") } } diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index c1a81a9..d29757e 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -12,8 +12,6 @@ struct ResidenceDetailView: View { @State private var showEditResidence = false @State private var showEditTask = false @State private var selectedTaskForEdit: TaskDetail? - @State private var showInProgressTasks = false - @State private var showDoneTasks = false @State private var selectedTaskForComplete: TaskDetail? var body: some View { @@ -39,8 +37,6 @@ struct ResidenceDetailView: View { if let tasksResponse = tasksResponse { TasksSection( tasksResponse: tasksResponse, - showInProgressTasks: $showInProgressTasks, - showDoneTasks: $showDoneTasks, onEditTask: { task in selectedTaskForEdit = task showEditTask = true diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 0adceb1..8af8771 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -145,12 +145,13 @@ struct TaskCard: View { description: "Remove all debris from gutters", category: TaskCategory(id: 1, name: "maintenance", description: ""), priority: TaskPriority(id: 2, name: "medium", displayName: "", description: ""), - frequency: TaskFrequency(id: 1, name: "monthly", displayName: "30"), + frequency: TaskFrequency(id: 1, name: "monthly", displayName: "30", daySpan: 0, notifyDays: 0), status: TaskStatus(id: 1, name: "pending", displayName: "", description: ""), dueDate: "2024-12-15", estimatedCost: "150.00", actualCost: nil, notes: nil, + archived: false, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", nextScheduledDate: nil, diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index d662aa9..39771ad 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -3,8 +3,6 @@ import ComposeApp struct TasksSection: View { let tasksResponse: TasksByResidenceResponse - @Binding var showInProgressTasks: Bool - @Binding var showDoneTasks: Bool let onEditTask: (TaskDetail) -> Void let onCancelTask: (TaskDetail) -> Void let onUncancelTask: (TaskDetail) -> Void @@ -13,104 +11,82 @@ struct TasksSection: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Tasks") - .font(.title2) - .fontWeight(.bold) + Text("Tasks") + .font(.title2) + .fontWeight(.bold) - Spacer() - - HStack(spacing: 8) { - TaskPill(count: Int32(tasksResponse.summary.upcoming), label: "Upcoming", color: .blue) - TaskPill(count: Int32(tasksResponse.summary.inProgress), label: "In Progress", color: .orange) - TaskPill(count: Int32(tasksResponse.summary.done), label: "Done", color: .green) - } - } - - if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty { + if tasksResponse.upcomingTasks.isEmpty && tasksResponse.inProgressTasks.isEmpty && tasksResponse.doneTasks.isEmpty && tasksResponse.archivedTasks.isEmpty { EmptyTasksView() } else { - // Upcoming tasks - ForEach(tasksResponse.upcomingTasks, id: \.id) { task in - TaskCard( - task: task, - onEdit: { onEditTask(task) }, - onCancel: { onCancelTask(task) }, - onUncancel: nil, - onMarkInProgress: { onMarkInProgress(task) }, - onComplete: { onCompleteTask(task) } - ) - } + GeometryReader { geometry in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 16) { + // Upcoming Column + TaskColumnView( + title: "Upcoming", + icon: "calendar", + color: .blue, + count: tasksResponse.upcomingTasks.count, + tasks: tasksResponse.upcomingTasks, + onEditTask: onEditTask, + onCancelTask: onCancelTask, + onUncancelTask: onUncancelTask, + onMarkInProgress: onMarkInProgress, + onCompleteTask: onCompleteTask + ) + .frame(width: geometry.size.width - 48) - // In Progress tasks section - if !tasksResponse.inProgressTasks.isEmpty { - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("In Progress (\(tasksResponse.inProgressTasks.count))", systemImage: "play.circle") - .font(.headline) - .foregroundColor(.orange) + // In Progress Column + TaskColumnView( + title: "In Progress", + icon: "play.circle", + color: .orange, + count: tasksResponse.inProgressTasks.count, + tasks: tasksResponse.inProgressTasks, + onEditTask: onEditTask, + onCancelTask: onCancelTask, + onUncancelTask: onUncancelTask, + onMarkInProgress: nil, + onCompleteTask: onCompleteTask + ) + .frame(width: geometry.size.width - 48) - Spacer() + // Done Column + TaskColumnView( + title: "Done", + icon: "checkmark.circle", + color: .green, + count: tasksResponse.doneTasks.count, + tasks: tasksResponse.doneTasks, + onEditTask: onEditTask, + onCancelTask: nil, + onUncancelTask: nil, + onMarkInProgress: nil, + onCompleteTask: nil + ) + .frame(width: geometry.size.width - 48) - Image(systemName: showInProgressTasks ? "chevron.up" : "chevron.down") - .foregroundColor(.secondary) - .font(.caption) - } - .padding(.top, 8) - .contentShape(Rectangle()) - .onTapGesture { - showInProgressTasks.toggle() - } - - if showInProgressTasks { - ForEach(tasksResponse.inProgressTasks, id: \.id) { task in - TaskCard( - task: task, - onEdit: { onEditTask(task) }, - onCancel: { onCancelTask(task) }, - onUncancel: nil, - onMarkInProgress: nil, - onComplete: { onCompleteTask(task) } - ) - } - } - } - } - - // Done tasks section - if !tasksResponse.doneTasks.isEmpty { - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Done (\(tasksResponse.doneTasks.count))", systemImage: "checkmark.circle") - .font(.headline) - .foregroundColor(.green) - - Spacer() - - Image(systemName: showDoneTasks ? "chevron.up" : "chevron.down") - .foregroundColor(.secondary) - .font(.caption) - } - .padding(.top, 8) - .contentShape(Rectangle()) - .onTapGesture { - showDoneTasks.toggle() - } - - if showDoneTasks { - ForEach(tasksResponse.doneTasks, id: \.id) { task in - TaskCard( - task: task, - onEdit: { onEditTask(task) }, - onCancel: nil, - onUncancel: nil, - onMarkInProgress: nil, - onComplete: nil - ) - } + // Archived Column + TaskColumnView( + title: "Archived", + icon: "archivebox", + color: .gray, + count: tasksResponse.archivedTasks.count, + tasks: tasksResponse.archivedTasks, + onEditTask: onEditTask, + onCancelTask: nil, + onUncancelTask: nil, + onMarkInProgress: nil, + onCompleteTask: nil + ) + .frame(width: geometry.size.width - 48) } + .scrollTargetLayout() + .padding(.horizontal, 16) } + .scrollTargetBehavior(.viewAligned) } + .frame(height: 500) } } } @@ -134,12 +110,13 @@ struct TasksSection: View { description: "Remove all debris", category: TaskCategory(id: 1, name: "maintenance", description: "General upkeep tasks"), priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: "Standard priority"), - frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly"), + frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0), status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: "Awaiting completion"), dueDate: "2024-12-15", estimatedCost: "150.00", actualCost: nil, notes: nil, + archived: false, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", nextScheduledDate: nil, @@ -156,22 +133,22 @@ struct TasksSection: View { description: "Kitchen sink fixed", category: TaskCategory(id: 2, name: "plumbing", description: "Plumbing tasks"), priority: TaskPriority(id: 3, name: "high", displayName: "High", description: "High priority"), - frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time"), + frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0), status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: "Task completed"), dueDate: "2024-11-01", estimatedCost: "200.00", - actualCost: "185.00", + actualCost: nil, notes: nil, + archived: false, createdAt: "2024-10-01T00:00:00Z", updatedAt: "2024-11-05T00:00:00Z", nextScheduledDate: nil, showCompletedButton: false, completions: [] ) - ] + ], + archivedTasks: [] ), - showInProgressTasks: .constant(true), - showDoneTasks: .constant(true), onEditTask: { _ in }, onCancelTask: { _ in }, onUncancelTask: { _ in }, diff --git a/iosApp/iosApp/Task/AddTaskView.swift b/iosApp/iosApp/Task/AddTaskView.swift index 7f96930..0261bc3 100644 --- a/iosApp/iosApp/Task/AddTaskView.swift +++ b/iosApp/iosApp/Task/AddTaskView.swift @@ -214,7 +214,6 @@ struct AddTaskView: View { frequency: Int32(frequency.id), intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt, priority: Int32(priority.id), - status: Int32(status.id), dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost ) diff --git a/iosApp/iosApp/Task/AddTaskWithResidenceView.swift b/iosApp/iosApp/Task/AddTaskWithResidenceView.swift new file mode 100644 index 0000000..d1581b3 --- /dev/null +++ b/iosApp/iosApp/Task/AddTaskWithResidenceView.swift @@ -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: []) +} diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index dc63698..eb8d3f1 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -3,21 +3,32 @@ import ComposeApp struct AllTasksView: View { @StateObject private var taskViewModel = TaskViewModel() + @StateObject private var residenceViewModel = ResidenceViewModel() @State private var tasksResponse: AllTasksResponse? @State private var isLoadingTasks = false @State private var tasksError: String? @State private var showAddTask = false @State private var showEditTask = false @State private var selectedTaskForEdit: TaskDetail? - @State private var showInProgressTasks = false - @State private var showDoneTasks = false @State private var selectedTaskForComplete: TaskDetail? - + + private var hasNoTasks: Bool { + guard let response = tasksResponse else { return true } + return response.upcomingTasks.isEmpty && + response.inProgressTasks.isEmpty && + response.doneTasks.isEmpty && + response.archivedTasks.isEmpty + } + + private var hasTasks: Bool { + !hasNoTasks + } + var body: some View { ZStack { Color(.systemGroupedBackground) .ignoresSafeArea() - + if isLoadingTasks { ProgressView() } else if let error = tasksError { @@ -25,68 +36,179 @@ struct AllTasksView: View { loadAllTasks() } } else if let tasksResponse = tasksResponse { - ScrollView { - VStack(spacing: 16) { - // Header Card - VStack(spacing: 12) { - Image(systemName: "checklist") - .font(.system(size: 48)) - .foregroundStyle(.blue.gradient) - - Text("All Tasks") - .font(.title) - .fontWeight(.bold) - - Text("Tasks across all your properties") - .font(.subheadline) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) - .padding(.horizontal) - .padding(.top) - - // Tasks Section - AllTasksSectionView( - tasksResponse: tasksResponse, - showInProgressTasks: $showInProgressTasks, - showDoneTasks: $showDoneTasks, - onEditTask: { task in - selectedTaskForEdit = task - showEditTask = true - }, - onCancelTask: { task in - taskViewModel.cancelTask(id: task.id) { _ in - loadAllTasks() - } - }, - onUncancelTask: { task in - taskViewModel.uncancelTask(id: task.id) { _ in - loadAllTasks() - } - }, - onMarkInProgress: { task in - taskViewModel.markInProgress(id: task.id) { success in - if success { - loadAllTasks() - } - } - }, - onCompleteTask: { task in - selectedTaskForComplete = task + if hasNoTasks { + // Empty state with big button + VStack(spacing: 24) { + Spacer() + + Image(systemName: "checklist") + .font(.system(size: 64)) + .foregroundStyle(.blue.opacity(0.6)) + + Text("No tasks yet") + .font(.title2) + .fontWeight(.semibold) + + Text("Create your first task to get started") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button(action: { + showAddTask = true + }) { + HStack(spacing: 8) { + Image(systemName: "plus") + Text("Add Task") + .fontWeight(.semibold) } - ) - .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: 50) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 48) + .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) + + if residenceViewModel.myResidences?.residences.isEmpty ?? true { + Text("Add a property first from the Residences tab") + .font(.caption) + .foregroundColor(.red) + } + + Spacer() + } + .padding() + } else { + GeometryReader { geometry in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 16) { + // Upcoming Column + TaskColumnView( + title: "Upcoming", + icon: "calendar", + color: .blue, + count: tasksResponse.upcomingTasks.count, + tasks: tasksResponse.upcomingTasks, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: { task in + taskViewModel.cancelTask(id: task.id) { _ in + loadAllTasks() + } + }, + onUncancelTask: { task in + taskViewModel.uncancelTask(id: task.id) { _ in + loadAllTasks() + } + }, + onMarkInProgress: { task in + taskViewModel.markInProgress(id: task.id) { success in + if success { + loadAllTasks() + } + } + }, + onCompleteTask: { task in + selectedTaskForComplete = task + } + ) + .frame(width: geometry.size.width - 48) + + + // In Progress Column + TaskColumnView( + title: "In Progress", + icon: "play.circle", + color: .orange, + count: tasksResponse.inProgressTasks.count, + tasks: tasksResponse.inProgressTasks, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: { task in + taskViewModel.cancelTask(id: task.id) { _ in + loadAllTasks() + } + }, + onUncancelTask: { task in + taskViewModel.uncancelTask(id: task.id) { _ in + loadAllTasks() + } + }, + onMarkInProgress: nil, + onCompleteTask: { task in + selectedTaskForComplete = task + } + ) + .frame(width: geometry.size.width - 48) + + // Done Column + TaskColumnView( + title: "Done", + icon: "checkmark.circle", + color: .green, + count: tasksResponse.doneTasks.count, + tasks: tasksResponse.doneTasks, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: nil, + onUncancelTask: nil, + onMarkInProgress: nil, + onCompleteTask: nil + ) + .frame(width: geometry.size.width - 48) + + // Archived Column + TaskColumnView( + title: "Archived", + icon: "archivebox", + color: .gray, + count: tasksResponse.archivedTasks.count, + tasks: tasksResponse.archivedTasks, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: nil, + onUncancelTask: nil, + onMarkInProgress: nil, + onCompleteTask: nil + ) + .frame(width: geometry.size.width - 48) + } + .scrollTargetLayout() + .padding(.horizontal, 16) + } + .scrollTargetBehavior(.viewAligned) } - .padding(.bottom) } } } + .ignoresSafeArea(edges: .bottom) .navigationTitle("All Tasks") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showAddTask = true + }) { + Image(systemName: "plus") + } + .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) + } + } + .sheet(isPresented: $showAddTask) { + AddTaskWithResidenceView( + isPresented: $showAddTask, + residences: residenceViewModel.myResidences?.residences.toResidences() ?? [], + ) + } .sheet(isPresented: $showEditTask) { if let task = selectedTaskForEdit { EditTaskView(task: task, isPresented: $showEditTask) @@ -98,6 +220,11 @@ struct AllTasksView: View { loadAllTasks() } } + .onChange(of: showAddTask) { isShowing in + if !isShowing { + loadAllTasks() + } + } .onChange(of: showEditTask) { isShowing in if !isShowing { loadAllTasks() @@ -105,155 +232,118 @@ struct AllTasksView: View { } .onAppear { loadAllTasks() + residenceViewModel.loadMyResidences() + } +} + +private func loadAllTasks() { + guard let token = TokenStorage.shared.getToken() else { return } + + isLoadingTasks = true + tasksError = nil + + let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) + taskApi.getTasks(token: token, days: 30) { result, error in + if let successResult = result as? ApiResultSuccess { + self.tasksResponse = successResult.data + self.isLoadingTasks = false + } else if let errorResult = result as? ApiResultError { + self.tasksError = errorResult.message + self.isLoadingTasks = false + } else if let error = error { + self.tasksError = error.localizedDescription + self.isLoadingTasks = false } } +} +} - private func loadAllTasks() { - guard let token = TokenStorage.shared.getToken() else { return } - - isLoadingTasks = true - tasksError = nil - - let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) - taskApi.getTasks(token: token, days: 30) { result, error in - if let successResult = result as? ApiResultSuccess { - self.tasksResponse = successResult.data - self.isLoadingTasks = false - } else if let errorResult = result as? ApiResultError { - self.tasksError = errorResult.message - self.isLoadingTasks = false - } else if let error = error { - self.tasksError = error.localizedDescription - self.isLoadingTasks = false +struct TaskColumnView: View { + let title: String + let icon: String + let color: Color + let count: Int + let tasks: [TaskDetail] + let onEditTask: (TaskDetail) -> Void + let onCancelTask: ((TaskDetail) -> Void)? + let onUncancelTask: ((TaskDetail) -> Void)? + let onMarkInProgress: ((TaskDetail) -> Void)? + let onCompleteTask: ((TaskDetail) -> Void)? + + var body: some View { + VStack(spacing: 0) { + // Tasks List + ScrollView { + VStack(spacing: 16) { + // Header + HStack(spacing: 8) { + Image(systemName: icon) + .font(.headline) + .foregroundColor(color) + + Text(title) + .font(.headline) + .foregroundColor(color) + + Spacer() + + Text("\(count)") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color) + .cornerRadius(12) + } + + if tasks.isEmpty { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundColor(color.opacity(0.3)) + + Text("No tasks") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + } else { + ForEach(tasks, id: \.id) { task in + TaskCard( + task: task, + onEdit: { onEditTask(task) }, + onCancel: onCancelTask != nil ? { onCancelTask?(task) } : nil, + onUncancel: onUncancelTask != nil ? { onUncancelTask?(task) } : nil, + onMarkInProgress: onMarkInProgress != nil ? { onMarkInProgress?(task) } : nil, + onComplete: onCompleteTask != nil ? { onCompleteTask?(task) } : nil + ) + } + } + } } } } } -struct AllTasksSectionView: View { - let tasksResponse: AllTasksResponse - @Binding var showInProgressTasks: Bool - @Binding var showDoneTasks: Bool - let onEditTask: (TaskDetail) -> Void - let onCancelTask: (TaskDetail) -> Void - let onUncancelTask: (TaskDetail) -> Void - let onMarkInProgress: (TaskDetail) -> Void - let onCompleteTask: (TaskDetail) -> Void +// Extension to apply corner radius to specific corners +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Task summary pills - HStack(spacing: 8) { - TaskPill( - count: Int32(tasksResponse.summary.upcoming), - label: "Upcoming", - color: .blue - ) - - TaskPill( - count: Int32(tasksResponse.summary.inProgress), - label: "In Progress", - color: .orange - ) - - TaskPill( - count: Int32(tasksResponse.summary.done), - label: "Done", - color: .green - ) - } - .padding(.bottom, 4) - - // Upcoming tasks - if !tasksResponse.upcomingTasks.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Label("Upcoming (\(tasksResponse.upcomingTasks.count))", systemImage: "calendar") - .font(.headline) - .foregroundColor(.blue) - - ForEach(tasksResponse.upcomingTasks, id: \.id) { task in - TaskCard( - task: task, - onEdit: { onEditTask(task) }, - onCancel: { onCancelTask(task) }, - onUncancel: { onUncancelTask(task) }, - onMarkInProgress: { onMarkInProgress(task) }, - onComplete: { onCompleteTask(task) } - ) - } - } - } - - // In Progress section (collapsible) - if !tasksResponse.inProgressTasks.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Label("In Progress (\(tasksResponse.inProgressTasks.count))", systemImage: "play.circle") - .font(.headline) - .foregroundColor(.orange) - - Spacer() - - Image(systemName: showInProgressTasks ? "chevron.up" : "chevron.down") - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - showInProgressTasks.toggle() - } - } - - if showInProgressTasks { - ForEach(tasksResponse.inProgressTasks, id: \.id) { task in - TaskCard( - task: task, - onEdit: { onEditTask(task) }, - onCancel: { onCancelTask(task) }, - onUncancel: { onUncancelTask(task) }, - onMarkInProgress: nil, - onComplete: { onCompleteTask(task) } - ) - } - } - } - } - - // Done section (collapsible) - if !tasksResponse.doneTasks.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Label("Done (\(tasksResponse.doneTasks.count))", systemImage: "checkmark.circle") - .font(.headline) - .foregroundColor(.green) - - Spacer() - - Image(systemName: showDoneTasks ? "chevron.up" : "chevron.down") - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - showDoneTasks.toggle() - } - } - - if showDoneTasks { - ForEach(tasksResponse.doneTasks, id: \.id) { task in - TaskCard( - task: task, - onEdit: { onEditTask(task) }, - onCancel: nil, - onUncancel: nil, - onMarkInProgress: nil, - onComplete: nil - ) - } - } - } - } - } +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) } } @@ -262,3 +352,37 @@ struct AllTasksSectionView: View { AllTasksView() } } + + +extension Array where Element == ResidenceWithTasks { + /// Converts an array of ResidenceWithTasks into an array of Residence. + /// Adjust the mapping inside as needed to match your model initializers. + func toResidences() -> [Residence] { + return self.map { item in + return Residence( + id: item.id, + owner: KotlinInt(value: item.owner), + ownerUsername: item.ownerUsername, + name: item.name, + propertyType: item.propertyType, + streetAddress: item.streetAddress, + apartmentUnit: item.apartmentUnit, + city: item.city, + stateProvince: item.stateProvince, + postalCode: item.postalCode, + country: item.country, + bedrooms: item.bedrooms != nil ? KotlinInt(nonretainedObject: item.bedrooms!) : nil, + bathrooms: item.bathrooms != nil ? KotlinFloat(float: Float(item.bathrooms!)) : nil, + squareFootage: item.squareFootage != nil ? KotlinInt(nonretainedObject: item.squareFootage!) : nil, + lotSize: item.lotSize != nil ? KotlinFloat(float: Float(item.lotSize!)) : nil, + yearBuilt: item.yearBuilt != nil ? KotlinInt(nonretainedObject: item.yearBuilt!) : nil, + description: item.description, + purchaseDate: item.purchaseDate, + purchasePrice: item.purchasePrice, + isPrimary: item.isPrimary, + createdAt: item.createdAt, + updatedAt: item.updatedAt + ) + } + } +} diff --git a/iosApp/iosApp/Task/EditTaskView.swift b/iosApp/iosApp/Task/EditTaskView.swift index e9c2b83..a1e013e 100644 --- a/iosApp/iosApp/Task/EditTaskView.swift +++ b/iosApp/iosApp/Task/EditTaskView.swift @@ -149,7 +149,6 @@ struct EditTaskView: View { frequency: frequency.id, intervalDays: nil, priority: priority.id, - status: status.id, dueDate: dueDate, estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost )