wip
This commit is contained in:
@@ -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,
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<List<Task>> {
|
||||
suspend fun getTasks(token: String): ApiResult<List<CustomTask>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/") {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val data: PaginatedResponse<Task> = response.body()
|
||||
val data: PaginatedResponse<CustomTask> = 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<Task> {
|
||||
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<CustomTask> {
|
||||
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<Task> {
|
||||
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<CustomTask> {
|
||||
return try {
|
||||
val response = client.put("$baseUrl/tasks/$id/") {
|
||||
header("Authorization", "Token $token")
|
||||
|
||||
@@ -34,7 +34,7 @@ fun ResidenceDetailScreen(
|
||||
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(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<TaskDetail?>(null) }
|
||||
|
||||
@@ -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<List<Task>>>(ApiResult.Loading)
|
||||
val tasksState: StateFlow<ApiResult<List<Task>>> = _tasksState
|
||||
private val _tasksState = MutableStateFlow<ApiResult<List<CustomTask>>>(ApiResult.Loading)
|
||||
val tasksState: StateFlow<ApiResult<List<CustomTask>>> = _tasksState
|
||||
|
||||
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
||||
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
|
||||
|
||||
private val _taskAddNewTaskState = MutableStateFlow<ApiResult<Task>>(ApiResult.Loading)
|
||||
val taskAddNewTaskState: StateFlow<ApiResult<Task>> = _taskAddNewTaskState
|
||||
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Loading)
|
||||
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ android.nonTransitiveRClass=true
|
||||
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
|
||||
@@ -21,6 +21,11 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
7A237E53D5D71D9D6A361E29 /* Configuration */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E822E6B231E7783DE992578C /* iosApp */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -29,11 +34,6 @@
|
||||
path = iosApp;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7A237E53D5D71D9D6A361E29 /* Configuration */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
}
|
||||
|
||||
413
iosApp/iosApp/AddResidenceView.swift
Normal file
413
iosApp/iosApp/AddResidenceView.swift
Normal 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))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
165
iosApp/iosApp/HomeScreenView.swift
Normal file
165
iosApp/iosApp/HomeScreenView.swift
Normal 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()
|
||||
}
|
||||
145
iosApp/iosApp/Login/LoginView.swift
Normal file
145
iosApp/iosApp/Login/LoginView.swift
Normal 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()
|
||||
}
|
||||
140
iosApp/iosApp/Login/LoginViewModel.swift
Normal file
140
iosApp/iosApp/Login/LoginViewModel.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
87
iosApp/iosApp/LookupsManager.swift
Normal file
87
iosApp/iosApp/LookupsManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
100
iosApp/iosApp/MainTabView.swift
Normal file
100
iosApp/iosApp/MainTabView.swift
Normal 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()
|
||||
}
|
||||
171
iosApp/iosApp/Register/RegisterView.swift
Normal file
171
iosApp/iosApp/Register/RegisterView.swift
Normal 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()
|
||||
}
|
||||
105
iosApp/iosApp/Register/RegisterViewModel.swift
Normal file
105
iosApp/iosApp/Register/RegisterViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
400
iosApp/iosApp/Residence/ResidenceDetailView.swift
Normal file
400
iosApp/iosApp/Residence/ResidenceDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
124
iosApp/iosApp/Residence/ResidenceViewModel.swift
Normal file
124
iosApp/iosApp/Residence/ResidenceViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
275
iosApp/iosApp/Residence/ResidencesListView.swift
Normal file
275
iosApp/iosApp/Residence/ResidencesListView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
71
iosApp/iosApp/StateFlowExtensions.swift
Normal file
71
iosApp/iosApp/StateFlowExtensions.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
432
iosApp/iosApp/Task/AddTaskView.swift
Normal file
432
iosApp/iosApp/Task/AddTaskView.swift
Normal 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))
|
||||
}
|
||||
60
iosApp/iosApp/Task/TaskViewModel.swift
Normal file
60
iosApp/iosApp/Task/TaskViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import SwiftUI
|
||||
struct iOSApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user