diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt rename to composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index 59b9ace..d04f561 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class Task( +data class CustomTask ( val id: Int, val residence: Int, @SerialName("created_by") val createdBy: Int, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt index 88b98ff..4c81175 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt @@ -74,8 +74,8 @@ data class ResidenceSummary( val country: String, @SerialName("is_primary") val isPrimary: Boolean, @SerialName("task_summary") val taskSummary: TaskSummary, - @SerialName("last_completed_task") val lastCompletedTask: Task?, - @SerialName("next_upcoming_task") val nextUpcomingTask: Task?, + @SerialName("last_completed_task") val lastCompletedCustomTask: CustomTask?, + @SerialName("next_upcoming_task") val nextUpcomingCustomTask: CustomTask?, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt index b2c04e4..2667b4b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -9,14 +9,14 @@ import io.ktor.http.* class TaskApi(private val client: HttpClient = ApiClient.httpClient) { private val baseUrl = ApiClient.getBaseUrl() - suspend fun getTasks(token: String): ApiResult> { + suspend fun getTasks(token: String): ApiResult> { return try { val response = client.get("$baseUrl/tasks/") { header("Authorization", "Token $token") } if (response.status.isSuccess()) { - val data: PaginatedResponse = response.body() + val data: PaginatedResponse = response.body() ApiResult.Success(data.results) } else { ApiResult.Error("Failed to fetch tasks", response.status.value) @@ -42,7 +42,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult { + suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult { return try { val response = client.post("$baseUrl/tasks/") { header("Authorization", "Token $token") @@ -60,7 +60,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult { + suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult { return try { val response = client.put("$baseUrl/tasks/$id/") { header("Authorization", "Token $token") 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 f5af724..e8650cc 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 @@ -34,7 +34,7 @@ fun ResidenceDetailScreen( var residenceState by remember { mutableStateOf>(ApiResult.Loading) } val tasksState by residenceViewModel.residenceTasksState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState() - val taskAddNewTaskState by taskViewModel.taskAddNewTaskState.collectAsState() + val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState() var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } 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 b373857..35f4851 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -2,10 +2,8 @@ package com.mycrib.android.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.mycrib.shared.models.ResidenceCreateRequest -import com.mycrib.shared.models.Task +import com.mycrib.shared.models.CustomTask import com.mycrib.shared.models.TaskCreateRequest -import com.mycrib.shared.models.TaskDetail import com.mycrib.shared.models.TasksByResidenceResponse import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.TaskApi @@ -17,14 +15,14 @@ import kotlinx.coroutines.launch class TaskViewModel : ViewModel() { private val taskApi = TaskApi() - private val _tasksState = MutableStateFlow>>(ApiResult.Loading) - val tasksState: StateFlow>> = _tasksState + private val _tasksState = MutableStateFlow>>(ApiResult.Loading) + val tasksState: StateFlow>> = _tasksState private val _tasksByResidenceState = MutableStateFlow>(ApiResult.Loading) val tasksByResidenceState: StateFlow> = _tasksByResidenceState - private val _taskAddNewTaskState = MutableStateFlow>(ApiResult.Loading) - val taskAddNewTaskState: StateFlow> = _taskAddNewTaskState + private val _taskAddNewCustomTaskState = MutableStateFlow>(ApiResult.Loading) + val taskAddNewCustomTaskState: StateFlow> = _taskAddNewCustomTaskState fun loadTasks() { viewModelScope.launch { @@ -52,17 +50,17 @@ class TaskViewModel : ViewModel() { fun createNewTask(request: TaskCreateRequest) { viewModelScope.launch { - _taskAddNewTaskState.value = ApiResult.Loading + _taskAddNewCustomTaskState.value = ApiResult.Loading try { - _taskAddNewTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request) + _taskAddNewCustomTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request) } catch (e: Exception) { - _taskAddNewTaskState.value = ApiResult.Error(e.message ?: "Unknown error") + _taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error") } } } fun resetAddTaskState() { - _taskAddNewTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it + _taskAddNewCustomTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it } } diff --git a/gradle.properties b/gradle.properties index 3f2310f..7b6da0f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,4 +12,6 @@ android.nonTransitiveRClass=true android.useAndroidX=true -kotlin.native.binary.objcDisposeOnMain=false \ No newline at end of file +kotlin.native.binary.objcDisposeOnMain=false + +org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index a389a0c..2c59016 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -21,6 +21,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 7A237E53D5D71D9D6A361E29 /* Configuration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Configuration; + sourceTree = ""; + }; E822E6B231E7783DE992578C /* iosApp */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -29,11 +34,6 @@ path = iosApp; sourceTree = ""; }; - 7A237E53D5D71D9D6A361E29 /* Configuration */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Configuration; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -167,6 +167,92 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 0248CABA5A5197845F2E5C26 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 468E4A6C96BEEFB382150D37 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 80B0F01D77D413305F161C14 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */; @@ -232,64 +318,6 @@ }; name = Debug; }; - 468E4A6C96BEEFB382150D37 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */; - baseConfigurationReferenceRelativePath = Config.xcconfig; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; E767E942685C7832D51FF978 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -318,46 +346,9 @@ }; name = Debug; }; - 0248CABA5A5197845F2E5C26 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - F25B3A5CCAC6BFCC21CD4636 /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 80B0F01D77D413305F161C14 /* Debug */, - 468E4A6C96BEEFB382150D37 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 293B4412461C9407D900D07D /* Build configuration list for PBXNativeTarget "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -367,7 +358,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F25B3A5CCAC6BFCC21CD4636 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 80B0F01D77D413305F161C14 /* Debug */, + 468E4A6C96BEEFB382150D37 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 6A3E1D84F9F1A2FD92A75A6C /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp/AddResidenceView.swift b/iosApp/iosApp/AddResidenceView.swift new file mode 100644 index 0000000..911d257 --- /dev/null +++ b/iosApp/iosApp/AddResidenceView.swift @@ -0,0 +1,413 @@ +import SwiftUI +import ComposeApp + +struct AddResidenceView: View { + @Binding var isPresented: Bool + @StateObject private var viewModel = ResidenceViewModel() + @StateObject private var lookupsManager = LookupsManager.shared + @FocusState private var focusedField: Field? + + // Form fields + @State private var name: String = "" + @State private var selectedPropertyType: ResidenceType? + @State private var streetAddress: String = "" + @State private var apartmentUnit: String = "" + @State private var city: String = "" + @State private var stateProvince: String = "" + @State private var postalCode: String = "" + @State private var country: String = "USA" + @State private var bedrooms: String = "" + @State private var bathrooms: String = "" + @State private var squareFootage: String = "" + @State private var lotSize: String = "" + @State private var yearBuilt: String = "" + @State private var description: String = "" + @State private var isPrimary: Bool = false + + // Validation errors + @State private var nameError: String = "" + @State private var streetAddressError: String = "" + @State private var cityError: String = "" + @State private var stateProvinceError: String = "" + @State private var postalCodeError: String = "" + + // Picker state + @State private var showPropertyTypePicker = false + + enum Field { + case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country + case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description + } + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Required Information Section + VStack(alignment: .leading, spacing: 16) { + Text("Required Information") + .font(.headline) + .foregroundColor(.blue) + + FormTextField( + label: "Property Name", + text: $name, + error: nameError, + placeholder: "My Home", + focusedField: $focusedField, + field: .name + ) + + // Property Type Picker + VStack(alignment: .leading, spacing: 8) { + Text("Property Type") + .font(.subheadline) + .foregroundColor(.secondary) + + Button(action: { + showPropertyTypePicker = true + }) { + HStack { + Text(selectedPropertyType?.name ?? "Select Type") + .foregroundColor(selectedPropertyType == nil ? .gray : .primary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.gray) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(8) + } + } + + FormTextField( + label: "Street Address", + text: $streetAddress, + error: streetAddressError, + placeholder: "123 Main St", + focusedField: $focusedField, + field: .streetAddress + ) + + FormTextField( + label: "Apartment/Unit (Optional)", + text: $apartmentUnit, + error: "", + placeholder: "Apt 4B", + focusedField: $focusedField, + field: .apartmentUnit + ) + + FormTextField( + label: "City", + text: $city, + error: cityError, + placeholder: "San Francisco", + focusedField: $focusedField, + field: .city + ) + + FormTextField( + label: "State/Province", + text: $stateProvince, + error: stateProvinceError, + placeholder: "CA", + focusedField: $focusedField, + field: .stateProvince + ) + + FormTextField( + label: "Postal Code", + text: $postalCode, + error: postalCodeError, + placeholder: "94102", + focusedField: $focusedField, + field: .postalCode + ) + + FormTextField( + label: "Country", + text: $country, + error: "", + placeholder: "USA", + focusedField: $focusedField, + field: .country + ) + } + + // Optional Information Section + VStack(alignment: .leading, spacing: 16) { + Text("Optional Information") + .font(.headline) + .foregroundColor(.blue) + + HStack(spacing: 12) { + FormTextField( + label: "Bedrooms", + text: $bedrooms, + error: "", + placeholder: "3", + focusedField: $focusedField, + field: .bedrooms, + keyboardType: .numberPad + ) + + FormTextField( + label: "Bathrooms", + text: $bathrooms, + error: "", + placeholder: "2.5", + focusedField: $focusedField, + field: .bathrooms, + keyboardType: .decimalPad + ) + } + + FormTextField( + label: "Square Footage", + text: $squareFootage, + error: "", + placeholder: "1800", + focusedField: $focusedField, + field: .squareFootage, + keyboardType: .numberPad + ) + + FormTextField( + label: "Lot Size (acres)", + text: $lotSize, + error: "", + placeholder: "0.25", + focusedField: $focusedField, + field: .lotSize, + keyboardType: .decimalPad + ) + + FormTextField( + label: "Year Built", + text: $yearBuilt, + error: "", + placeholder: "2010", + focusedField: $focusedField, + field: .yearBuilt, + keyboardType: .numberPad + ) + + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .font(.subheadline) + .foregroundColor(.secondary) + + TextEditor(text: $description) + .frame(height: 100) + .padding(8) + .background(Color(.systemBackground)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + Toggle("Primary Residence", isOn: $isPrimary) + .font(.subheadline) + } + + // Submit Button + Button(action: submitForm) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Add Property") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.isLoading ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(viewModel.isLoading) + } + .padding() + } + } + .navigationTitle("Add Residence") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + isPresented = false + } + } + } + .sheet(isPresented: $showPropertyTypePicker) { + PropertyTypePickerView( + propertyTypes: lookupsManager.residenceTypes, + selectedType: $selectedPropertyType, + isPresented: $showPropertyTypePicker + ) + } + .onAppear { + setDefaults() + } + } + } + + private func setDefaults() { + // Set default property type if not already set + if selectedPropertyType == nil && !lookupsManager.residenceTypes.isEmpty { + selectedPropertyType = lookupsManager.residenceTypes.first + } + } + + private func validateForm() -> Bool { + var isValid = true + + if name.isEmpty { + nameError = "Name is required" + isValid = false + } else { + nameError = "" + } + + if streetAddress.isEmpty { + streetAddressError = "Street address is required" + isValid = false + } else { + streetAddressError = "" + } + + if city.isEmpty { + cityError = "City is required" + isValid = false + } else { + cityError = "" + } + + if stateProvince.isEmpty { + stateProvinceError = "State/Province is required" + isValid = false + } else { + stateProvinceError = "" + } + + if postalCode.isEmpty { + postalCodeError = "Postal code is required" + isValid = false + } else { + postalCodeError = "" + } + + return isValid + } + + private func submitForm() { + guard validateForm() else { return } + guard let propertyType = selectedPropertyType else { + // Show error + return + } + + let request = ResidenceCreateRequest( + name: name, + propertyType: Int32(propertyType.id), + streetAddress: streetAddress, + apartmentUnit: apartmentUnit.isEmpty ? nil : apartmentUnit, + city: city, + stateProvince: stateProvince, + postalCode: postalCode, + country: country, + bedrooms: Int32(bedrooms) as? KotlinInt, + bathrooms: Float(bathrooms) as? KotlinFloat, + squareFootage: Int32(squareFootage) as? KotlinInt, + lotSize: Float(lotSize) as? KotlinFloat, + yearBuilt: Int32(yearBuilt) as? KotlinInt, + description: description.isEmpty ? nil : description, + purchaseDate: nil, + purchasePrice: nil, + isPrimary: isPrimary + ) + + viewModel.createResidence(request: request) { success in + if success { + isPresented = false + } + } + } +} + +struct FormTextField: View { + let label: String + @Binding var text: String + let error: String + let placeholder: String + var focusedField: FocusState.Binding + let field: AddResidenceView.Field + var keyboardType: UIKeyboardType = .default + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + + TextField(placeholder, text: $text) + .textFieldStyle(.roundedBorder) + .keyboardType(keyboardType) + .focused(focusedField, equals: field) + + if !error.isEmpty { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + } + } +} + +struct PropertyTypePickerView: View { + let propertyTypes: [ResidenceType] + @Binding var selectedType: ResidenceType? + @Binding var isPresented: Bool + + var body: some View { + NavigationView { + List(propertyTypes, id: \.id) { type in + Button(action: { + selectedType = type + isPresented = false + }) { + HStack { + Text(type.name) + Spacer() + if selectedType?.id == type.id { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } + .navigationTitle("Select Property Type") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + isPresented = false + } + } + } + } + } +} + +#Preview { + AddResidenceView(isPresented: .constant(true)) +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index c765ff2..75c3f77 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -8,14 +8,39 @@ struct ComposeView: UIViewControllerRepresentable { } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + } struct ContentView: View { var body: some View { - ComposeView() + CustomView() .ignoresSafeArea() } } +struct CustomView: View { + var body: some View { + Text("Custom view") + .task { + await ViewModel().somethingRandom() + } + } +} - +class ViewModel { + func somethingRandom() async { + TokenStorage().initialize(manager: TokenManager.init()) +// TokenStorage.initialize(TokenManager.getInstance()) + + let api = ResidenceApi(client: ApiClient_iosKt.createHttpClient()) + + api.deleteResidence(token: "token", id: 32) { result, error in + if let error = error { + print("Interop error: \(error)") + return + } + guard let result = result else { return } + } + } +} diff --git a/iosApp/iosApp/HomeScreenView.swift b/iosApp/iosApp/HomeScreenView.swift new file mode 100644 index 0000000..4531e61 --- /dev/null +++ b/iosApp/iosApp/HomeScreenView.swift @@ -0,0 +1,165 @@ +import SwiftUI +import ComposeApp + +struct HomeScreenView: View { + @StateObject private var viewModel = ResidenceViewModel() + @StateObject private var loginViewModel = LoginViewModel() + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if viewModel.isLoading { + ProgressView() + } else { + ScrollView { + VStack(spacing: 20) { + // Overview Card + if let summary = viewModel.residenceSummary { + OverviewCard(summary: summary.summary) + } + + // Navigation Cards + VStack(spacing: 16) { + NavigationLink(destination: ResidencesListView()) { + HomeNavigationCard( + icon: "house.fill", + title: "Residences", + subtitle: "Manage your properties" + ) + } + + NavigationLink(destination: Text("Tasks (Coming Soon)")) { + HomeNavigationCard( + icon: "checkmark.circle.fill", + title: "Tasks", + subtitle: "View and manage tasks" + ) + } + } + .padding(.horizontal) + } + .padding(.vertical) + } + } + } + .navigationTitle("MyCrib") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + loginViewModel.logout() + }) { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + } + } + .onAppear { + viewModel.loadResidenceSummary() + } + } + } +} + +struct OverviewCard: View { + let summary: OverallSummary + + var body: some View { + VStack(spacing: 16) { + HStack { + Image(systemName: "chart.bar.fill") + .font(.title3) + Text("Overview") + .font(.title2) + .fontWeight(.bold) + Spacer() + } + + HStack(spacing: 40) { + StatView( + icon: "house.fill", + value: "\(summary.totalResidences)", + label: "Properties" + ) + + StatView( + icon: "list.bullet", + value: "\(summary.totalTasks)", + label: "Total Tasks" + ) + + StatView( + icon: "clock.fill", + value: "\(summary.totalPending)", + label: "Pending" + ) + } + } + .padding(20) + .background(Color.blue.opacity(0.1)) + .cornerRadius(16) + .padding(.horizontal) + } +} + +struct StatView: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(.blue) + + Text(value) + .font(.title) + .fontWeight(.bold) + + Text(label) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +struct HomeNavigationCard: View { + let icon: String + let title: String + let subtitle: String + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 36)) + .foregroundColor(.blue) + .frame(width: 60) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + .padding(20) + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } +} + +#Preview { + HomeScreenView() +} diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift new file mode 100644 index 0000000..acaa66f --- /dev/null +++ b/iosApp/iosApp/Login/LoginView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +struct LoginView: View { + @StateObject private var viewModel = LoginViewModel() + @FocusState private var focusedField: Field? + @State private var showingRegister = false + + enum Field { + case username, password + } + + var body: some View { + NavigationView { + ZStack { + // Background + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Logo or App Name + VStack(spacing: 8) { + Image(systemName: "house.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + .foregroundColor(.blue) + + Text("MyCrib") + .font(.largeTitle) + .fontWeight(.bold) + } + .padding(.top, 60) + .padding(.bottom, 20) + + // Login Form + VStack(spacing: 16) { + // Username Field + VStack(alignment: .leading, spacing: 8) { + Text("Username") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Enter your username", text: $viewModel.username) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit { + focusedField = .password + } + } + + // Password Field + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.subheadline) + .foregroundColor(.secondary) + + SecureField("Enter your password", text: $viewModel.password) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .password) + .submitLabel(.go) + .onSubmit { + viewModel.login() + } + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + + Spacer() + + Button(action: viewModel.clearError) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + // Login Button + Button(action: viewModel.login) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Login") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.isLoading ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(viewModel.isLoading) + + // Forgot Password / Sign Up Links + HStack(spacing: 4) { + Text("Don't have an account?") + .font(.caption) + .foregroundColor(.secondary) + + Button("Sign Up") { + showingRegister = true + } + .font(.caption) + .fontWeight(.semibold) + } + } + .padding(.horizontal, 24) + + Spacer() + } + } + } + .navigationTitle("Welcome Back") + .navigationBarTitleDisplayMode(.inline) + .fullScreenCover(isPresented: $viewModel.isAuthenticated) { + MainTabView() + } + .sheet(isPresented: $showingRegister) { + RegisterView() + } + } + } +} + +// MARK: - Preview +#Preview { + LoginView() +} diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift new file mode 100644 index 0000000..1243912 --- /dev/null +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -0,0 +1,140 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class LoginViewModel: ObservableObject { + // MARK: - Published Properties + @Published var username: String = "" + @Published var password: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isAuthenticated: Bool = false + + // MARK: - Private Properties + private let authApi: AuthApi + private let tokenStorage: TokenStorage + + // MARK: - Initialization + init() { + self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) + self.tokenStorage = TokenStorage() + + // Initialize TokenStorage with platform-specific manager + self.tokenStorage.initialize(manager: TokenManager.init()) + + // Check if user is already logged in + checkAuthenticationStatus() + } + + // MARK: - Public Methods + func login() { + guard !username.isEmpty else { + errorMessage = "Username is required" + return + } + + guard !password.isEmpty else { + errorMessage = "Password is required" + return + } + + isLoading = true + errorMessage = nil + + let loginRequest = LoginRequest(username: username, password: password) + + do { + // Call the KMM AuthApi login method + authApi.login(request: loginRequest) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleSuccess(results: successResult) + return + } + + if let error = error { + self.handleError(error: error) + return + } + + self.isLoading = false + self.isAuthenticated = false + print("uknown error") + } + } + } + + @MainActor + func handleError(error: any Error) { + self.isLoading = false + self.isAuthenticated = false + print(error) + } + + @MainActor + func handleSuccess(results: ApiResultSuccess) { + if let token = results.data?.token, + let user = results.data?.user { + self.tokenStorage.saveToken(token: token) + + // Initialize lookups repository after successful login + LookupsManager.shared.initialize() + + // Update authentication state + self.isAuthenticated = true + self.isLoading = false + + print("Login successful! Token: token") + print("User: \(user.username)") + } + } + + func logout() { + let token = tokenStorage.getToken() + + if let token = token { + // Call logout API + authApi.logout(token: token) { _, _ in + // Ignore result, clear token anyway + } + } + + // Clear token from storage + tokenStorage.clearToken() + + // Clear lookups data on logout + LookupsManager.shared.clear() + + // Reset state + isAuthenticated = false + username = "" + password = "" + errorMessage = nil + } + + func clearError() { + errorMessage = nil + } + + // MARK: - Private Methods + private func checkAuthenticationStatus() { + isAuthenticated = tokenStorage.hasToken() + + // If already authenticated, initialize lookups + if isAuthenticated { + LookupsManager.shared.initialize() + } + } +} + +// MARK: - Error Types +enum LoginError: LocalizedError { + case unknownError + + var errorDescription: String? { + switch self { + case .unknownError: + return "An unknown error occurred" + } + } +} diff --git a/iosApp/iosApp/LookupsManager.swift b/iosApp/iosApp/LookupsManager.swift new file mode 100644 index 0000000..847e07b --- /dev/null +++ b/iosApp/iosApp/LookupsManager.swift @@ -0,0 +1,87 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class LookupsManager: ObservableObject { + static let shared = LookupsManager() + + // Published properties for SwiftUI + @Published var residenceTypes: [ResidenceType] = [] + @Published var taskCategories: [TaskCategory] = [] + @Published var taskFrequencies: [TaskFrequency] = [] + @Published var taskPriorities: [TaskPriority] = [] + @Published var taskStatuses: [TaskStatus] = [] + @Published var isLoading: Bool = false + @Published var isInitialized: Bool = false + + private let repository = LookupsRepository.shared + + private init() { + // Start observing the repository flows + startObserving() + } + + private func startObserving() { + // Observe residence types + Task { + for await types in repository.residenceTypes.residenceTypesAsyncSequence { + self.residenceTypes = types + } + } + + // Observe task categories + Task { + for await categories in repository.taskCategories.taskCategoriesAsyncSequence { + self.taskCategories = categories + } + } + + // Observe task frequencies + Task { + for await frequencies in repository.taskFrequencies.taskFrequenciesAsyncSequence { + self.taskFrequencies = frequencies + } + } + + // Observe task priorities + Task { + for await priorities in repository.taskPriorities.taskPrioritiesAsyncSequence { + self.taskPriorities = priorities + } + } + + // Observe task statuses + Task { + for await statuses in repository.taskStatuses.taskStatusesAsyncSequence { + self.taskStatuses = statuses + } + } + + // Observe loading state + Task { + for await loading in repository.isLoading.boolAsyncSequence { + self.isLoading = loading + } + } + + // Observe initialized state + Task { + for await initialized in repository.isInitialized.boolAsyncSequence { + self.isInitialized = initialized + } + } + } + + func initialize() { + repository.initialize() + } + + func refresh() { + repository.refresh() + } + + func clear() { + repository.clear() + } +} diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift new file mode 100644 index 0000000..e160ff8 --- /dev/null +++ b/iosApp/iosApp/MainTabView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct MainTabView: View { + @StateObject private var loginViewModel = LoginViewModel() + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + NavigationView { + ResidencesListView() + } + .tabItem { + Label("Residences", systemImage: "house.fill") + } + .tag(0) + + Text("Tasks (Coming Soon)") + .tabItem { + Label("Tasks", systemImage: "checkmark.circle.fill") + } + .tag(1) + + ProfileView() + .tabItem { + Label("Profile", systemImage: "person.fill") + } + .tag(2) + } + } +} + +struct ProfileView: View { + @StateObject private var loginViewModel = LoginViewModel() + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationView { + List { + Section { + HStack { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 60, height: 60) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 4) { + Text("User Profile") + .font(.headline) + + Text("Manage your account") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + } + + Section("Settings") { + NavigationLink(destination: Text("Account Settings")) { + Label("Account Settings", systemImage: "gear") + } + + NavigationLink(destination: Text("Notifications")) { + Label("Notifications", systemImage: "bell") + } + + NavigationLink(destination: Text("Privacy")) { + Label("Privacy", systemImage: "lock.shield") + } + } + + Section { + Button(action: { + loginViewModel.logout() + }) { + Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundColor(.red) + } + } + + Section { + VStack(alignment: .leading, spacing: 4) { + Text("MyCrib") + .font(.caption) + .fontWeight(.semibold) + + Text("Version 1.0.0") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("Profile") + } + } +} + +#Preview { + MainTabView() +} diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift new file mode 100644 index 0000000..ba70966 --- /dev/null +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -0,0 +1,171 @@ +import SwiftUI + +struct RegisterView: View { + @StateObject private var viewModel = RegisterViewModel() + @Environment(\.dismiss) var dismiss + @FocusState private var focusedField: Field? + + enum Field { + case username, email, password, confirmPassword + } + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Icon and Title + VStack(spacing: 12) { + Image(systemName: "person.badge.plus") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .foregroundColor(.blue) + + Text("Join MyCrib") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Start managing your properties today") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.top, 40) + .padding(.bottom, 20) + + // Registration Form + VStack(spacing: 16) { + // Username Field + VStack(alignment: .leading, spacing: 8) { + Text("Username") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Enter your username", text: $viewModel.username) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit { + focusedField = .email + } + } + + // Email Field + VStack(alignment: .leading, spacing: 8) { + Text("Email") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Enter your email", text: $viewModel.email) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .focused($focusedField, equals: .email) + .submitLabel(.next) + .onSubmit { + focusedField = .password + } + } + + // Password Field + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.subheadline) + .foregroundColor(.secondary) + + SecureField("Enter your password", text: $viewModel.password) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .password) + .submitLabel(.next) + .onSubmit { + focusedField = .confirmPassword + } + } + + // Confirm Password Field + VStack(alignment: .leading, spacing: 8) { + Text("Confirm Password") + .font(.subheadline) + .foregroundColor(.secondary) + + SecureField("Confirm your password", text: $viewModel.confirmPassword) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { + viewModel.register() + } + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + + Spacer() + + Button(action: viewModel.clearError) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + // Register Button + Button(action: viewModel.register) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Create Account") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.isLoading ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(viewModel.isLoading) + } + .padding(.horizontal, 24) + + Spacer() + } + } + } + .navigationTitle("Create Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + } + .fullScreenCover(isPresented: $viewModel.isRegistered) { + MainTabView() + } + } + } +} + +#Preview { + RegisterView() +} diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift new file mode 100644 index 0000000..2d82ba9 --- /dev/null +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -0,0 +1,105 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class RegisterViewModel: ObservableObject { + // MARK: - Published Properties + @Published var username: String = "" + @Published var email: String = "" + @Published var password: String = "" + @Published var confirmPassword: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isRegistered: Bool = false + + // MARK: - Private Properties + private let authApi: AuthApi + private let tokenStorage: TokenStorage + + // MARK: - Initialization + init() { + self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) + self.tokenStorage = TokenStorage() + self.tokenStorage.initialize(manager: TokenManager.init()) + } + + // MARK: - Public Methods + func register() { + // Validation + guard !username.isEmpty else { + errorMessage = "Username is required" + return + } + + guard !email.isEmpty else { + errorMessage = "Email is required" + return + } + + guard !password.isEmpty else { + errorMessage = "Password is required" + return + } + + guard password == confirmPassword else { + errorMessage = "Passwords do not match" + return + } + + isLoading = true + errorMessage = nil + + let registerRequest = RegisterRequest( + username: username, + email: email, + password: password, + firstName: nil, + lastName: nil + ) + + authApi.register(request: registerRequest) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleSuccess(results: successResult) + return + } + + if let error = error { + self.handleError(error: error) + return + } + + self.isLoading = false + print("Unknown error during registration") + } + } + + @MainActor + func handleError(error: any Error) { + self.isLoading = false + self.errorMessage = error.localizedDescription + print(error) + } + + @MainActor + func handleSuccess(results: ApiResultSuccess) { + if let token = results.data?.token, + let user = results.data?.user { + self.tokenStorage.saveToken(token: token) + + // Initialize lookups repository after successful registration + LookupsManager.shared.initialize() + + // Update registration state + self.isRegistered = true + self.isLoading = false + + print("Registration successful! Token saved") + print("User: \(user.username)") + } + } + + func clearError() { + errorMessage = nil + } +} diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift new file mode 100644 index 0000000..f0601a6 --- /dev/null +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -0,0 +1,400 @@ +import SwiftUI +import ComposeApp + +struct ResidenceDetailView: View { + let residenceId: Int32 + @StateObject private var viewModel = ResidenceViewModel() + @State private var residenceWithTasks: ResidenceWithTasks? + @State private var isLoadingTasks = false + @State private var tasksError: String? + @State private var showAddTask = false + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.errorMessage { + ErrorView(message: error) { + loadResidenceData() + } + } else if let residence = viewModel.selectedResidence { + ScrollView { + VStack(spacing: 16) { + // Property Header Card + PropertyHeaderCard(residence: residence) + .padding(.horizontal) + .padding(.top) + + // Tasks Section + if let residenceWithTasks = residenceWithTasks { + TasksSection(residenceWithTasks: residenceWithTasks) + .padding(.horizontal) + } else if isLoadingTasks { + ProgressView("Loading tasks...") + } else if let tasksError = tasksError { + Text("Error loading tasks: \(tasksError)") + .foregroundColor(.red) + .padding() + } + } + .padding(.bottom) + } + } + } + .navigationTitle("Property Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showAddTask = true + }) { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddTask) { + AddTaskView(residenceId: residenceId, isPresented: $showAddTask) + } + .onChange(of: showAddTask) { isShowing in + if !isShowing { + // Refresh tasks when sheet is dismissed + loadResidenceWithTasks() + } + } + .onAppear { + loadResidenceData() + } + } + + private func loadResidenceData() { + viewModel.getResidence(id: residenceId) + loadResidenceWithTasks() + } + + private func loadResidenceWithTasks() { + guard let token = TokenStorage().getToken() else { return } + + isLoadingTasks = true + tasksError = nil + + let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient()) + residenceApi.getMyResidences(token: token) { result, error in + if let successResult = result as? ApiResultSuccess { + if let residence = successResult.data?.residences.first(where: { $0.id == residenceId }) { + self.residenceWithTasks = residence + } + 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 PropertyHeaderCard: View { + let residence: Residence + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "house.fill") + .font(.title2) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 4) { + Text(residence.name) + .font(.title2) + .fontWeight(.bold) + + Text(residence.propertyType) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + + Divider() + + // Address + VStack(alignment: .leading, spacing: 4) { + Label(residence.streetAddress, systemImage: "mappin.circle.fill") + .font(.subheadline) + + Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)") + .font(.subheadline) + .foregroundColor(.secondary) + + if !residence.country.isEmpty { + Text(residence.country) + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Property Details + if let bedrooms = residence.bedrooms, + let bathrooms = residence.bathrooms { + Divider() + + HStack(spacing: 24) { + PropertyDetailItem(icon: "bed.double.fill", value: "\(bedrooms)", label: "Beds") + PropertyDetailItem(icon: "shower.fill", value: String(format: "%.1f", bathrooms), label: "Baths") + + if let sqft = residence.squareFootage { + PropertyDetailItem(icon: "square.fill", value: "\(sqft)", label: "Sq Ft") + } + } + } + +// if !residence.description.isEmpty { +// Divider() +// +// Text(residence.) +// .font(.body) +// .foregroundColor(.secondary) +// } + } + .padding(20) + .background(Color.blue.opacity(0.1)) + .cornerRadius(16) + } +} + +struct PropertyDetailItem: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundColor(.blue) + + Text(value) + .font(.subheadline) + .fontWeight(.semibold) + + Text(label) + .font(.caption2) + .foregroundColor(.secondary) + } + } +} + +struct TasksSection: View { + let residenceWithTasks: ResidenceWithTasks + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Tasks") + .font(.title2) + .fontWeight(.bold) + + Spacer() + + // Task Summary Pills + HStack(spacing: 8) { + TaskPill(count: residenceWithTasks.taskSummary.total, label: "Total", color: .blue) + TaskPill(count: residenceWithTasks.taskSummary.pending, label: "Pending", color: .orange) + TaskPill(count: residenceWithTasks.taskSummary.completed, label: "Done", color: .green) + } + } + + if residenceWithTasks.tasks.isEmpty { + EmptyTasksView() + } else { + ForEach(residenceWithTasks.tasks, id: \.id) { task in + TaskCard(task: task) + } + } + } + } +} + +struct TaskPill: View { + let count: Int32 + let label: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Text("\(count)") + .font(.caption) + .fontWeight(.bold) + + Text(label) + .font(.caption2) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.2)) + .foregroundColor(color) + .cornerRadius(8) + } +} + +struct TaskCard: View { + let task: TaskDetail + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(task.title) + .font(.headline) + .foregroundColor(.primary) + + if let status = task.status { + StatusBadge(status: status.name) + } + } + + Spacer() + + PriorityBadge(priority: task.priority.name) + } + + if let description = task.description_, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + + HStack { + + Label(task.frequency.displayName, systemImage: "repeat") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + + Label(formatDate(task.dueDate), systemImage: "calendar") + .font(.caption) + .foregroundColor(.secondary) + } + + // Completion count + if task.completions.count > 0 { + Divider() + + HStack { + Image(systemName: "checkmark.circle") + .foregroundColor(.green) + Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding(16) + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 2) + } + + private func formatDate(_ dateString: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + if let date = formatter.date(from: dateString) { + formatter.dateStyle = .medium + return formatter.string(from: date) + } + return dateString + } +} + +struct StatusBadge: View { + let status: String + + var body: some View { + Text(formatStatus(status)) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(statusColor.opacity(0.2)) + .foregroundColor(statusColor) + .cornerRadius(6) + } + + private func formatStatus(_ status: String) -> String { + switch status { + case "in_progress": return "In Progress" + default: return status.capitalized + } + } + + private var statusColor: Color { + switch status { + case "completed": return .green + case "in_progress": return .blue + case "pending": return .orange + case "cancelled": return .red + default: return .gray + } + } +} + +struct PriorityBadge: View { + let priority: String + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2) + + Text(priority.capitalized) + .font(.caption) + .fontWeight(.medium) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(priorityColor.opacity(0.2)) + .foregroundColor(priorityColor) + .cornerRadius(6) + } + + private var priorityColor: Color { + switch priority.lowercased() { + case "high": return .red + case "medium": return .orange + case "low": return .green + default: return .gray + } + } +} + +struct EmptyTasksView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle") + .font(.system(size: 48)) + .foregroundColor(.gray.opacity(0.5)) + + Text("No tasks yet") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(32) + .background(Color(.systemBackground)) + .cornerRadius(12) + } +} + +#Preview { + NavigationView { + ResidenceDetailView(residenceId: 1) + } +} diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift new file mode 100644 index 0000000..dc95e00 --- /dev/null +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -0,0 +1,124 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class ResidenceViewModel: ObservableObject { + // MARK: - Published Properties + @Published var residenceSummary: ResidenceSummaryResponse? + @Published var myResidences: MyResidencesResponse? + @Published var selectedResidence: Residence? + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + // MARK: - Private Properties + private let residenceApi: ResidenceApi + private let tokenStorage: TokenStorage + + // MARK: - Initialization + init() { + self.residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient()) + self.tokenStorage = TokenStorage() + self.tokenStorage.initialize(manager: TokenManager.init()) + } + + // MARK: - Public Methods + func loadResidenceSummary() { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + residenceApi.getResidenceSummary(token: token) { result, error in + if let successResult = result as? ApiResultSuccess { + self.residenceSummary = successResult.data + self.isLoading = false + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + + func loadMyResidences() { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + residenceApi.getMyResidences(token: token) { result, error in + if let successResult = result as? ApiResultSuccess { + self.myResidences = successResult.data + self.isLoading = false + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + + func getResidence(id: Int32) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + residenceApi.getResidence(token: token, id: id) { result, error in + if let successResult = result as? ApiResultSuccess { + self.selectedResidence = successResult.data + self.isLoading = false + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + + func createResidence(request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isLoading = true + errorMessage = nil + + residenceApi.createResidence(token: token, request: request) { result, error in + if result is ApiResultSuccess { + self.isLoading = false + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } + } + + func clearError() { + errorMessage = nil + } +} diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift new file mode 100644 index 0000000..111cd8c --- /dev/null +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -0,0 +1,275 @@ +import SwiftUI +import ComposeApp + +struct ResidencesListView: View { + @StateObject private var viewModel = ResidenceViewModel() + @State private var showingAddResidence = false + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.errorMessage { + ErrorView(message: error) { + viewModel.loadMyResidences() + } + } else if let response = viewModel.myResidences { + if response.residences.isEmpty { + EmptyResidencesView() + } else { + ScrollView { + VStack(spacing: 16) { + // Summary Card + SummaryCard(summary: response.summary) + .padding(.horizontal) + .padding(.top) + + // Properties Header + HStack { + Text("Your Properties") + .font(.title2) + .fontWeight(.bold) + Spacer() + } + .padding(.horizontal) + .padding(.top, 8) + + // Residences List + ForEach(response.residences, id: \.id) { residence in + NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) { + ResidenceCard(residence: residence) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal) + } + .padding(.bottom) + } + } + } + } + .navigationTitle("My Properties") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showingAddResidence = true + }) { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingAddResidence) { + AddResidenceView(isPresented: $showingAddResidence) + } + .onAppear { + viewModel.loadMyResidences() + } + } +} + +struct SummaryCard: View { + let summary: MyResidencesSummary + + var body: some View { + VStack(spacing: 16) { + HStack { + Image(systemName: "chart.bar.doc.horizontal") + .font(.title3) + Text("Overview") + .font(.title3) + .fontWeight(.bold) + Spacer() + } + + HStack(spacing: 20) { + SummaryStatView( + icon: "house.fill", + value: "\(summary.totalResidences)", + label: "Properties" + ) + + SummaryStatView( + icon: "list.bullet", + value: "\(summary.totalTasks)", + label: "Total Tasks" + ) + } + + Divider() + + HStack(spacing: 20) { + SummaryStatView( + icon: "calendar", + value: "\(summary.tasksDueNextWeek)", + label: "Due This Week" + ) + + SummaryStatView( + icon: "calendar.badge.clock", + value: "\(summary.tasksDueNextMonth)", + label: "Due This Month" + ) + } + } + .padding(20) + .background(Color.blue.opacity(0.1)) + .cornerRadius(16) + } +} + +struct SummaryStatView: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title3) + .foregroundColor(.blue) + + Text(value) + .font(.title2) + .fontWeight(.bold) + + Text(label) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } +} + +struct ResidenceCard: View { + let residence: ResidenceWithTasks + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Property Name and Address + VStack(alignment: .leading, spacing: 4) { + Text(residence.name) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.primary) + + Text(residence.streetAddress) + .font(.subheadline) + .foregroundColor(.secondary) + + Text("\(residence.city), \(residence.stateProvince)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Divider() + + // Task Stats + HStack(spacing: 24) { + TaskStatChip( + icon: "list.bullet", + value: "\(residence.taskSummary.total)", + label: "Tasks", + color: .blue + ) + + TaskStatChip( + icon: "checkmark.circle.fill", + value: "\(residence.taskSummary.completed)", + label: "Done", + color: .green + ) + + TaskStatChip( + icon: "clock.fill", + value: "\(residence.taskSummary.pending)", + label: "Pending", + color: .orange + ) + } + } + .padding(20) + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } +} + +struct TaskStatChip: View { + let icon: String + let value: String + let label: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundColor(color) + + Text(value) + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(color) + + Text(label) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +struct EmptyResidencesView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "house") + .font(.system(size: 80)) + .foregroundColor(.blue.opacity(0.6)) + + Text("No properties yet") + .font(.title2) + .fontWeight(.semibold) + + Text("Add your first property to get started!") + .font(.body) + .foregroundColor(.secondary) + } + } +} + +struct ErrorView: View { + let message: String + let retryAction: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 64)) + .foregroundColor(.red) + + Text("Error: \(message)") + .foregroundColor(.red) + .multilineTextAlignment(.center) + + Button(action: retryAction) { + Text("Retry") + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .padding() + } +} + +#Preview { + NavigationView { + ResidencesListView() + } +} diff --git a/iosApp/iosApp/StateFlowExtensions.swift b/iosApp/iosApp/StateFlowExtensions.swift new file mode 100644 index 0000000..4048045 --- /dev/null +++ b/iosApp/iosApp/StateFlowExtensions.swift @@ -0,0 +1,71 @@ +import Foundation +import ComposeApp +import Combine + +// MARK: - StateFlow AsyncSequence Extension +extension Kotlinx_coroutines_coreStateFlow { + func asAsyncSequence() -> AsyncStream { + return AsyncStream { continuation in + // Create a flow collector that bridges to Swift continuation + let collector = StateFlowCollector { value in + if let typedValue = value as? T { + continuation.yield(typedValue) + } + } + + // Start collecting in a Task to handle the suspend function + let task = Task { + do { + try await self.collect(collector: collector) + } catch { + // Handle cancellation or other errors + continuation.finish() + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} + +// Helper class to bridge Kotlin FlowCollector to Swift closure +private class StateFlowCollector: Kotlinx_coroutines_coreFlowCollector { + private let onValue: (Any?) -> Void + + init(onValue: @escaping (Any?) -> Void) { + self.onValue = onValue + } + + func emit(value: Any?) async throws { + onValue(value) + } +} + +// MARK: - Convenience AsyncSequence Extensions for specific types +extension Kotlinx_coroutines_coreStateFlow { + var residenceTypesAsyncSequence: AsyncStream<[ResidenceType]> { + return asAsyncSequence() + } + + var taskCategoriesAsyncSequence: AsyncStream<[TaskCategory]> { + return asAsyncSequence() + } + + var taskFrequenciesAsyncSequence: AsyncStream<[TaskFrequency]> { + return asAsyncSequence() + } + + var taskPrioritiesAsyncSequence: AsyncStream<[TaskPriority]> { + return asAsyncSequence() + } + + var taskStatusesAsyncSequence: AsyncStream<[TaskStatus]> { + return asAsyncSequence() + } + + var boolAsyncSequence: AsyncStream { + return asAsyncSequence() + } +} \ No newline at end of file diff --git a/iosApp/iosApp/Task/AddTaskView.swift b/iosApp/iosApp/Task/AddTaskView.swift new file mode 100644 index 0000000..175b188 --- /dev/null +++ b/iosApp/iosApp/Task/AddTaskView.swift @@ -0,0 +1,432 @@ +import SwiftUI +import ComposeApp + +struct AddTaskView: View { + let residenceId: Int32 + @Binding var isPresented: Bool + @StateObject private var viewModel = TaskViewModel() + @StateObject private var lookupsManager = LookupsManager.shared + @FocusState private var focusedField: Field? + + // Form fields + @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 = "" + + // Picker states + @State private var showCategoryPicker = false + @State private var showFrequencyPicker = false + @State private var showPriorityPicker = false + @State private var showStatusPicker = false + + enum Field { + case title, description, intervalDays, estimatedCost + } + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if lookupsManager.isLoading { + VStack(spacing: 16) { + ProgressView() + Text("Loading lookup data...") + .foregroundColor(.secondary) + } + } else { + ScrollView { + VStack(spacing: 24) { + // Task Information Section + VStack(alignment: .leading, spacing: 16) { + Text("Task Information") + .font(.headline) + .foregroundColor(.blue) + + // Title Field + VStack(alignment: .leading, spacing: 8) { + Text("Task Title *") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("e.g., Clean gutters", text: $title) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .title) + + if !titleError.isEmpty { + Text(titleError) + .font(.caption) + .foregroundColor(.red) + } + } + + // Description Field + VStack(alignment: .leading, spacing: 8) { + Text("Description (Optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + TextEditor(text: $description) + .frame(height: 100) + .padding(8) + .background(Color(.systemBackground)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + // Category Picker + PickerField( + label: "Category *", + selectedItem: selectedCategory?.name ?? "Select Category", + showPicker: $showCategoryPicker + ) + + // Frequency Picker + PickerField( + label: "Frequency *", + selectedItem: selectedFrequency?.displayName ?? "Select Frequency", + showPicker: $showFrequencyPicker + ) + + // Interval Days (if applicable) + if selectedFrequency?.name != "once" { + VStack(alignment: .leading, spacing: 8) { + Text("Custom Interval (days, optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Leave empty for default", text: $intervalDays) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .focused($focusedField, equals: .intervalDays) + } + } + + // Priority Picker + PickerField( + label: "Priority *", + selectedItem: selectedPriority?.displayName ?? "Select Priority", + showPicker: $showPriorityPicker + ) + + // Status Picker + PickerField( + label: "Status *", + selectedItem: selectedStatus?.displayName ?? "Select Status", + showPicker: $showStatusPicker + ) + + // Due Date Picker + VStack(alignment: .leading, spacing: 8) { + Text("Due Date *") + .font(.subheadline) + .foregroundColor(.secondary) + + DatePicker("", selection: $dueDate, displayedComponents: .date) + .datePickerStyle(.compact) + .labelsHidden() + } + + // Estimated Cost Field + VStack(alignment: .leading, spacing: 8) { + Text("Estimated Cost (Optional)") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("e.g., 150.00", text: $estimatedCost) + .textFieldStyle(.roundedBorder) + .keyboardType(.decimalPad) + .focused($focusedField, equals: .estimatedCost) + } + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + + Spacer() + + Button(action: viewModel.clearError) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + // Submit Button + Button(action: submitForm) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Create Task") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.isLoading ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(viewModel.isLoading) + } + .padding() + } + } + } + .navigationTitle("Add Task") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + isPresented = false + } + } + } + .sheet(isPresented: $showCategoryPicker) { + LookupPickerView( + title: "Select Category", + items: lookupsManager.taskCategories.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.name) }, + selectedId: selectedCategory?.id, + isPresented: $showCategoryPicker, + onSelect: { id in + selectedCategory = lookupsManager.taskCategories.first { $0.id == id } + } + ) + } + .sheet(isPresented: $showFrequencyPicker) { + LookupPickerView( + title: "Select Frequency", + items: lookupsManager.taskFrequencies.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) }, + selectedId: selectedFrequency?.id, + isPresented: $showFrequencyPicker, + onSelect: { id in + selectedFrequency = lookupsManager.taskFrequencies.first { $0.id == id } + } + ) + } + .sheet(isPresented: $showPriorityPicker) { + LookupPickerView( + title: "Select Priority", + items: lookupsManager.taskPriorities.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) }, + selectedId: selectedPriority?.id, + isPresented: $showPriorityPicker, + onSelect: { id in + selectedPriority = lookupsManager.taskPriorities.first { $0.id == id } + } + ) + } + .sheet(isPresented: $showStatusPicker) { + LookupPickerView( + title: "Select Status", + items: lookupsManager.taskStatuses.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) }, + selectedId: selectedStatus?.id, + isPresented: $showStatusPicker, + onSelect: { id in + selectedStatus = lookupsManager.taskStatuses.first { $0.id == id } + } + ) + } + .onAppear { + setDefaults() + } + .onChange(of: viewModel.taskCreated) { created in + if created { + isPresented = false + } + } + } + } + + private func setDefaults() { + // Set default values if not already set + if selectedCategory == nil && !lookupsManager.taskCategories.isEmpty { + selectedCategory = lookupsManager.taskCategories.first + } + + if selectedFrequency == nil && !lookupsManager.taskFrequencies.isEmpty { + // Default to "once" + selectedFrequency = lookupsManager.taskFrequencies.first { $0.name == "once" } ?? lookupsManager.taskFrequencies.first + } + + if selectedPriority == nil && !lookupsManager.taskPriorities.isEmpty { + // Default to "medium" + selectedPriority = lookupsManager.taskPriorities.first { $0.name == "medium" } ?? lookupsManager.taskPriorities.first + } + + if selectedStatus == nil && !lookupsManager.taskStatuses.isEmpty { + // Default to "pending" + selectedStatus = lookupsManager.taskStatuses.first { $0.name == "pending" } ?? lookupsManager.taskStatuses.first + } + } + + private func validateForm() -> Bool { + var isValid = true + + 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 category = selectedCategory, + let frequency = selectedFrequency, + let priority = selectedPriority, + let status = selectedStatus else { + return + } + + // Format date as yyyy-MM-dd + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dueDateString = dateFormatter.string(from: dueDate) + + let request = TaskCreateRequest( + residence: residenceId, + 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), + status: Int32(status.id), + dueDate: dueDateString, + estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost + ) + + viewModel.createTask(request: request) { success in + if success { + // View will dismiss automatically via onChange + } + } + } +} + +// MARK: - Supporting Views +struct PickerField: View { + let label: String + let selectedItem: String + @Binding var showPicker: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + + Button(action: { + showPicker = true + }) { + HStack { + Text(selectedItem) + .foregroundColor(selectedItem.contains("Select") ? .gray : .primary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.gray) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(8) + } + } + } +} + +struct LookupItem: Identifiable { + let id: Int32 + let name: String + let displayName: String +} + +struct LookupPickerView: View { + let title: String + let items: [LookupItem] + let selectedId: Int32? + @Binding var isPresented: Bool + let onSelect: (Int32) -> Void + + var body: some View { + NavigationView { + List(items) { item in + Button(action: { + onSelect(item.id) + isPresented = false + }) { + HStack { + Text(item.displayName) + .foregroundColor(.primary) + Spacer() + if selectedId == item.id { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + isPresented = false + } + } + } + } + } +} + +#Preview { + AddTaskView(residenceId: 1, isPresented: .constant(true)) +} diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift new file mode 100644 index 0000000..781c57e --- /dev/null +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -0,0 +1,60 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class TaskViewModel: ObservableObject { + // MARK: - Published Properties + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var taskCreated: Bool = false + + // MARK: - Private Properties + private let taskApi: TaskApi + private let tokenStorage: TokenStorage + + // MARK: - Initialization + init() { + self.taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) + self.tokenStorage = TokenStorage() + self.tokenStorage.initialize(manager: TokenManager.init()) + } + + // MARK: - Public Methods + func createTask(request: TaskCreateRequest, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isLoading = true + errorMessage = nil + taskCreated = false + + taskApi.createTask(token: token, request: request) { result, error in + if result is ApiResultSuccess { + self.isLoading = false + self.taskCreated = true + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } + } + + func clearError() { + errorMessage = nil + } + + func resetState() { + taskCreated = false + errorMessage = nil + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index d83dca6..5d7d242 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -4,7 +4,7 @@ import SwiftUI struct iOSApp: App { var body: some Scene { WindowGroup { - ContentView() + LoginView() } } } \ No newline at end of file