This commit is contained in:
Trey t
2025-11-05 10:38:46 -06:00
parent 025fcf677a
commit 2be3a5a3a8
23 changed files with 2837 additions and 124 deletions

View File

@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Task( data class CustomTask (
val id: Int, val id: Int,
val residence: Int, val residence: Int,
@SerialName("created_by") val createdBy: Int, @SerialName("created_by") val createdBy: Int,

View File

@@ -74,8 +74,8 @@ data class ResidenceSummary(
val country: String, val country: String,
@SerialName("is_primary") val isPrimary: Boolean, @SerialName("is_primary") val isPrimary: Boolean,
@SerialName("task_summary") val taskSummary: TaskSummary, @SerialName("task_summary") val taskSummary: TaskSummary,
@SerialName("last_completed_task") val lastCompletedTask: Task?, @SerialName("last_completed_task") val lastCompletedCustomTask: CustomTask?,
@SerialName("next_upcoming_task") val nextUpcomingTask: Task?, @SerialName("next_upcoming_task") val nextUpcomingCustomTask: CustomTask?,
@SerialName("created_at") val createdAt: String, @SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String @SerialName("updated_at") val updatedAt: String
) )

View File

@@ -9,14 +9,14 @@ import io.ktor.http.*
class TaskApi(private val client: HttpClient = ApiClient.httpClient) { class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl() private val baseUrl = ApiClient.getBaseUrl()
suspend fun getTasks(token: String): ApiResult<List<Task>> { suspend fun getTasks(token: String): ApiResult<List<CustomTask>> {
return try { return try {
val response = client.get("$baseUrl/tasks/") { val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token") header("Authorization", "Token $token")
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val data: PaginatedResponse<Task> = response.body() val data: PaginatedResponse<CustomTask> = response.body()
ApiResult.Success(data.results) ApiResult.Success(data.results)
} else { } else {
ApiResult.Error("Failed to fetch tasks", response.status.value) 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<Task> { suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<CustomTask> {
return try { return try {
val response = client.post("$baseUrl/tasks/") { val response = client.post("$baseUrl/tasks/") {
header("Authorization", "Token $token") 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<Task> { suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<CustomTask> {
return try { return try {
val response = client.put("$baseUrl/tasks/$id/") { val response = client.put("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header("Authorization", "Token $token")

View File

@@ -34,7 +34,7 @@ fun ResidenceDetailScreen(
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) } var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
val tasksState by residenceViewModel.residenceTasksState.collectAsState() val tasksState by residenceViewModel.residenceTasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.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 showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) } var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }

View File

@@ -2,10 +2,8 @@ package com.mycrib.android.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.mycrib.shared.models.ResidenceCreateRequest import com.mycrib.shared.models.CustomTask
import com.mycrib.shared.models.Task
import com.mycrib.shared.models.TaskCreateRequest import com.mycrib.shared.models.TaskCreateRequest
import com.mycrib.shared.models.TaskDetail
import com.mycrib.shared.models.TasksByResidenceResponse import com.mycrib.shared.models.TasksByResidenceResponse
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.TaskApi import com.mycrib.shared.network.TaskApi
@@ -17,14 +15,14 @@ import kotlinx.coroutines.launch
class TaskViewModel : ViewModel() { class TaskViewModel : ViewModel() {
private val taskApi = TaskApi() private val taskApi = TaskApi()
private val _tasksState = MutableStateFlow<ApiResult<List<Task>>>(ApiResult.Loading) private val _tasksState = MutableStateFlow<ApiResult<List<CustomTask>>>(ApiResult.Loading)
val tasksState: StateFlow<ApiResult<List<Task>>> = _tasksState val tasksState: StateFlow<ApiResult<List<CustomTask>>> = _tasksState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading) private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
private val _taskAddNewTaskState = MutableStateFlow<ApiResult<Task>>(ApiResult.Loading) private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Loading)
val taskAddNewTaskState: StateFlow<ApiResult<Task>> = _taskAddNewTaskState val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
fun loadTasks() { fun loadTasks() {
viewModelScope.launch { viewModelScope.launch {
@@ -52,17 +50,17 @@ class TaskViewModel : ViewModel() {
fun createNewTask(request: TaskCreateRequest) { fun createNewTask(request: TaskCreateRequest) {
viewModelScope.launch { viewModelScope.launch {
_taskAddNewTaskState.value = ApiResult.Loading _taskAddNewCustomTaskState.value = ApiResult.Loading
try { try {
_taskAddNewTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request) _taskAddNewCustomTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request)
} catch (e: Exception) { } catch (e: Exception) {
_taskAddNewTaskState.value = ApiResult.Error(e.message ?: "Unknown error") _taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
} }
} }
} }
fun resetAddTaskState() { fun resetAddTaskState() {
_taskAddNewTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it _taskAddNewCustomTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it
} }
} }

View File

@@ -13,3 +13,5 @@ android.useAndroidX=true
kotlin.native.binary.objcDisposeOnMain=false kotlin.native.binary.objcDisposeOnMain=false
org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home

View File

@@ -21,6 +21,11 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
7A237E53D5D71D9D6A361E29 /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
E822E6B231E7783DE992578C /* iosApp */ = { E822E6B231E7783DE992578C /* iosApp */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@@ -29,11 +34,6 @@
path = iosApp; path = iosApp;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7A237E53D5D71D9D6A361E29 /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -167,6 +167,92 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration 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 */ = { 80B0F01D77D413305F161C14 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */; baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */;
@@ -232,64 +318,6 @@
}; };
name = Debug; 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 */ = { E767E942685C7832D51FF978 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -318,46 +346,9 @@
}; };
name = Debug; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList 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" */ = { 293B4412461C9407D900D07D /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -367,6 +358,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
F25B3A5CCAC6BFCC21CD4636 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
80B0F01D77D413305F161C14 /* Debug */,
468E4A6C96BEEFB382150D37 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 6A3E1D84F9F1A2FD92A75A6C /* Project object */; rootObject = 6A3E1D84F9F1A2FD92A75A6C /* Project object */;

View File

@@ -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<AddResidenceView.Field?>.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))
}

View File

@@ -8,14 +8,39 @@ struct ComposeView: UIViewControllerRepresentable {
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
} }
struct ContentView: View { struct ContentView: View {
var body: some View { var body: some View {
ComposeView() CustomView()
.ignoresSafeArea() .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 }
}
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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<AuthResponse> {
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<AuthResponse>) {
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"
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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<AuthResponse> {
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<AuthResponse>) {
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
}
}

View File

@@ -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<MyResidencesResponse> {
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)
}
}

View File

@@ -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<ResidenceSummaryResponse> {
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<MyResidencesResponse> {
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<Residence> {
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<Residence> {
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
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
import ComposeApp
import Combine
// MARK: - StateFlow AsyncSequence Extension
extension Kotlinx_coroutines_coreStateFlow {
func asAsyncSequence<T>() -> AsyncStream<T> {
return AsyncStream<T> { continuation in
// Create a flow collector that bridges to Swift continuation
let collector = StateFlowCollector<T> { 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<T>: 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<Bool> {
return asAsyncSequence()
}
}

View File

@@ -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))
}

View File

@@ -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<TaskDetail> {
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
}
}

View File

@@ -4,7 +4,7 @@ import SwiftUI
struct iOSApp: App { struct iOSApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() LoginView()
} }
} }
} }