wip
This commit is contained in:
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Task(
|
data class CustomTask (
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val residence: Int,
|
val residence: Int,
|
||||||
@SerialName("created_by") val createdBy: Int,
|
@SerialName("created_by") val createdBy: Int,
|
||||||
@@ -74,8 +74,8 @@ data class ResidenceSummary(
|
|||||||
val country: String,
|
val country: String,
|
||||||
@SerialName("is_primary") val isPrimary: Boolean,
|
@SerialName("is_primary") val isPrimary: Boolean,
|
||||||
@SerialName("task_summary") val taskSummary: TaskSummary,
|
@SerialName("task_summary") val taskSummary: TaskSummary,
|
||||||
@SerialName("last_completed_task") val lastCompletedTask: Task?,
|
@SerialName("last_completed_task") val lastCompletedCustomTask: CustomTask?,
|
||||||
@SerialName("next_upcoming_task") val nextUpcomingTask: Task?,
|
@SerialName("next_upcoming_task") val nextUpcomingCustomTask: CustomTask?,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("updated_at") val updatedAt: String
|
@SerialName("updated_at") val updatedAt: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import io.ktor.http.*
|
|||||||
class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
private val baseUrl = ApiClient.getBaseUrl()
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
|
|
||||||
suspend fun getTasks(token: String): ApiResult<List<Task>> {
|
suspend fun getTasks(token: String): ApiResult<List<CustomTask>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/") {
|
val response = client.get("$baseUrl/tasks/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
val data: PaginatedResponse<Task> = response.body()
|
val data: PaginatedResponse<CustomTask> = response.body()
|
||||||
ApiResult.Success(data.results)
|
ApiResult.Success(data.results)
|
||||||
} else {
|
} else {
|
||||||
ApiResult.Error("Failed to fetch tasks", response.status.value)
|
ApiResult.Error("Failed to fetch tasks", response.status.value)
|
||||||
@@ -42,7 +42,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<Task> {
|
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<CustomTask> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/tasks/") {
|
val response = client.post("$baseUrl/tasks/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -60,7 +60,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<Task> {
|
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<CustomTask> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/tasks/$id/") {
|
val response = client.put("$baseUrl/tasks/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ fun ResidenceDetailScreen(
|
|||||||
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
|
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
|
||||||
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
||||||
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||||
val taskAddNewTaskState by taskViewModel.taskAddNewTaskState.collectAsState()
|
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
|
||||||
|
|
||||||
var showCompleteDialog by remember { mutableStateOf(false) }
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package com.mycrib.android.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.mycrib.shared.models.ResidenceCreateRequest
|
import com.mycrib.shared.models.CustomTask
|
||||||
import com.mycrib.shared.models.Task
|
|
||||||
import com.mycrib.shared.models.TaskCreateRequest
|
import com.mycrib.shared.models.TaskCreateRequest
|
||||||
import com.mycrib.shared.models.TaskDetail
|
|
||||||
import com.mycrib.shared.models.TasksByResidenceResponse
|
import com.mycrib.shared.models.TasksByResidenceResponse
|
||||||
import com.mycrib.shared.network.ApiResult
|
import com.mycrib.shared.network.ApiResult
|
||||||
import com.mycrib.shared.network.TaskApi
|
import com.mycrib.shared.network.TaskApi
|
||||||
@@ -17,14 +15,14 @@ import kotlinx.coroutines.launch
|
|||||||
class TaskViewModel : ViewModel() {
|
class TaskViewModel : ViewModel() {
|
||||||
private val taskApi = TaskApi()
|
private val taskApi = TaskApi()
|
||||||
|
|
||||||
private val _tasksState = MutableStateFlow<ApiResult<List<Task>>>(ApiResult.Loading)
|
private val _tasksState = MutableStateFlow<ApiResult<List<CustomTask>>>(ApiResult.Loading)
|
||||||
val tasksState: StateFlow<ApiResult<List<Task>>> = _tasksState
|
val tasksState: StateFlow<ApiResult<List<CustomTask>>> = _tasksState
|
||||||
|
|
||||||
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
||||||
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
|
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
|
||||||
|
|
||||||
private val _taskAddNewTaskState = MutableStateFlow<ApiResult<Task>>(ApiResult.Loading)
|
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Loading)
|
||||||
val taskAddNewTaskState: StateFlow<ApiResult<Task>> = _taskAddNewTaskState
|
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
||||||
|
|
||||||
fun loadTasks() {
|
fun loadTasks() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -52,17 +50,17 @@ class TaskViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun createNewTask(request: TaskCreateRequest) {
|
fun createNewTask(request: TaskCreateRequest) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_taskAddNewTaskState.value = ApiResult.Loading
|
_taskAddNewCustomTaskState.value = ApiResult.Loading
|
||||||
try {
|
try {
|
||||||
_taskAddNewTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request)
|
_taskAddNewCustomTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_taskAddNewTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
|
_taskAddNewCustomTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun resetAddTaskState() {
|
fun resetAddTaskState() {
|
||||||
_taskAddNewTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it
|
_taskAddNewCustomTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ android.useAndroidX=true
|
|||||||
|
|
||||||
|
|
||||||
kotlin.native.binary.objcDisposeOnMain=false
|
kotlin.native.binary.objcDisposeOnMain=false
|
||||||
|
|
||||||
|
org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home
|
||||||
@@ -21,6 +21,11 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7A237E53D5D71D9D6A361E29 /* Configuration */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Configuration;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
E822E6B231E7783DE992578C /* iosApp */ = {
|
E822E6B231E7783DE992578C /* iosApp */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
@@ -29,11 +34,6 @@
|
|||||||
path = iosApp;
|
path = iosApp;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
7A237E53D5D71D9D6A361E29 /* Configuration */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = Configuration;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -167,6 +167,92 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
0248CABA5A5197845F2E5C26 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ARCHS = arm64;
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
|
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
468E4A6C96BEEFB382150D37 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */;
|
||||||
|
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
80B0F01D77D413305F161C14 /* Debug */ = {
|
80B0F01D77D413305F161C14 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */;
|
baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */;
|
||||||
@@ -232,64 +318,6 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
468E4A6C96BEEFB382150D37 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReferenceAnchor = 7A237E53D5D71D9D6A361E29 /* Configuration */;
|
|
||||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
E767E942685C7832D51FF978 /* Debug */ = {
|
E767E942685C7832D51FF978 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -318,46 +346,9 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
0248CABA5A5197845F2E5C26 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ARCHS = arm64;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
|
||||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
F25B3A5CCAC6BFCC21CD4636 /* Build configuration list for PBXProject "iosApp" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
80B0F01D77D413305F161C14 /* Debug */,
|
|
||||||
468E4A6C96BEEFB382150D37 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
293B4412461C9407D900D07D /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
293B4412461C9407D900D07D /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@@ -367,6 +358,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
F25B3A5CCAC6BFCC21CD4636 /* Build configuration list for PBXProject "iosApp" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
80B0F01D77D413305F161C14 /* Debug */,
|
||||||
|
468E4A6C96BEEFB382150D37 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 6A3E1D84F9F1A2FD92A75A6C /* Project object */;
|
rootObject = 6A3E1D84F9F1A2FD92A75A6C /* Project object */;
|
||||||
|
|||||||
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) {}
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ComposeView()
|
CustomView()
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CustomView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Custom view")
|
||||||
|
.task {
|
||||||
|
await ViewModel().somethingRandom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewModel {
|
||||||
|
func somethingRandom() async {
|
||||||
|
TokenStorage().initialize(manager: TokenManager.init())
|
||||||
|
// TokenStorage.initialize(TokenManager.getInstance())
|
||||||
|
|
||||||
|
let api = ResidenceApi(client: ApiClient_iosKt.createHttpClient())
|
||||||
|
|
||||||
|
api.deleteResidence(token: "token", id: 32) { result, error in
|
||||||
|
if let error = error {
|
||||||
|
print("Interop error: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let result = result else { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 {
|
struct iOSApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
LoginView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user