diff --git a/.gitignore b/.gitignore index e37364f..a4228d2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,9 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings -# Project exclude paths +# Google Service +composeApp/google-service.json +**/google-service.json +google-service.json + +# Project exclude paths \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index bf7b850..137e3a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,4 +8,5 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.firebase.crashlytics) apply false + alias(libs.plugins.ksp) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index bf0c799..e69e0f7 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -34,6 +34,28 @@ kotlin { } sourceSets { + +// val commonMain by getting { +// kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") +// } +// +// // Android +// val androidMain by getting { +// kotlin.srcDir("build/generated/ksp/android/androidDebug/kotlin") +// kotlin.srcDir("build/generated/ksp/android/androidRelease/kotlin") +// } +// +// // iOS +// val iosX64Main by getting { +// kotlin.srcDir("build/generated/ksp/iosX64/iosX64Main/kotlin") +// } +// val iosArm64Main by getting { +// kotlin.srcDir("build/generated/ksp/iosArm64/iosArm64Main/kotlin") +// } +// val iosSimulatorArm64Main by getting { +// kotlin.srcDir("build/generated/ksp/iosSimulatorArm64/iosSimulatorArm64Main/kotlin") +// } + androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) @@ -60,11 +82,13 @@ kotlin { implementation(libs.adaptive) implementation(libs.adaptive.layout) implementation(libs.adaptive.navigation) + //koin - api(libs.koin.core) - api(libs.koin.annotations) + implementation(libs.koin.core) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.compose.viewmodel.navigation) + api(libs.koin.annotations) implementation(libs.ktor.client.core) implementation(libs.ktor.serialization.kotlinx.json) @@ -73,32 +97,42 @@ kotlin { implementation(libs.bundles.coil) + implementation(libs.kotlinx.datetime) + + implementation(libs.androidx.datastore.preferences) + + implementation(libs.calf.file.picker) + } commonTest.dependencies { implementation(libs.kotlin.test) + + implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) + implementation(libs.koin.test) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } } - - sourceSets.named("commonMain").configure { - kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") - } - } ksp { + //arg("KOIN_USE_COMPOSE_VIEWMODEL","true") arg("KOIN_CONFIG_CHECK","true") } -project.tasks.withType(KotlinCompilationTask::class.java).configureEach { - if (name != "kspCommonMainKotlinMetadata") { - dependsOn("kspCommonMainKotlinMetadata") - } -} +//tasks.withType>().configureEach { +// if (name != "kspCommonMainKotlinMetadata") { +// dependsOn("kspCommonMainKotlinMetadata") +// } +//} +// +//kotlin.sourceSets.getByName("commonMain") { +// kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") +//} android { @@ -132,5 +166,13 @@ android { dependencies { debugImplementation(compose.uiTooling) add("kspCommonMainMetadata", libs.koin.ksp.compiler) + + //Android + add("kspAndroid", libs.koin.ksp.compiler) + + // iOS (all targets you use) + add("kspIosX64", libs.koin.ksp.compiler) + add("kspIosArm64", libs.koin.ksp.compiler) + add("kspIosSimulatorArm64", libs.koin.ksp.compiler) } diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index f5bc803..315d338 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + + + + by preferencesDataStore( + name = "crafto_preferences" +) + +class DataStoreLocalDataSourceImp( + private val context: Context +) : StorageLocalDataSource { + + private val dataStore = context.dataStore + + override suspend fun saveString(key: String, value: String) { + val preferencesKey = stringPreferencesKey(key) + dataStore.edit { preferences -> + preferences[preferencesKey] = value + } + } + + override suspend fun getString(key: String): String? { + val preferencesKey = stringPreferencesKey(key) + return dataStore.data.map { preferences -> + preferences[preferencesKey] + }.first() + } + + override suspend fun saveStringSet(key: String, values: Set) { + val preferencesKey = stringSetPreferencesKey(key) + dataStore.edit { preferences -> + preferences[preferencesKey] = values + } + } + + override suspend fun getStringSet(key: String): Set? { + val preferencesKey = stringSetPreferencesKey(key) + return dataStore.data.map { preferences -> + preferences[preferencesKey] + }.first() + } + + override suspend fun saveLong(key: String, value: Long) { + val preferencesKey = longPreferencesKey(key) + dataStore.edit { preferences -> + preferences[preferencesKey] = value + } + } + + override suspend fun getLong(key: String): Long? { + val preferencesKey = longPreferencesKey(key) + return dataStore.data.map { preferences -> + preferences[preferencesKey] + }.first() + } + + override suspend fun saveBoolean(key: String, value: Boolean) { + val preferencesKey = booleanPreferencesKey(key) + dataStore.edit { preferences -> + preferences[preferencesKey] = value + } + } + + override suspend fun getBoolean(key: String): Boolean? { + val preferencesKey = booleanPreferencesKey(key) + return dataStore.data.map { preferences -> + preferences[preferencesKey] + }.first() + } + + override suspend fun remove(key: String) { + dataStore.edit { preferences -> + preferences.remove(stringPreferencesKey(key)) + preferences.remove(intPreferencesKey(key)) + preferences.remove(longPreferencesKey(key)) + preferences.remove(booleanPreferencesKey(key)) + preferences.remove(stringSetPreferencesKey(key)) + } + } + + override suspend fun removeAll() { + dataStore.edit { preferences -> + preferences.clear() + } + } + + override suspend fun getAllKeys(): Set { + return dataStore.data.map { preferences -> + preferences.asMap().keys.map { it.name }.toSet() + }.first() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/example/project/di/AndroidModule.kt b/composeApp/src/androidMain/kotlin/org/example/project/di/AndroidModule.kt new file mode 100644 index 0000000..b261258 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/example/project/di/AndroidModule.kt @@ -0,0 +1,12 @@ +package org.example.project.di + +import org.example.project.data.local.datasource.StorageLocalDataSource +import org.example.project.data.local.datasource.DataStoreLocalDataSourceImp +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val androidModule = module { + single { + DataStoreLocalDataSourceImp(androidContext()) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/example/project/util/AppLogger.kt b/composeApp/src/androidMain/kotlin/org/example/project/util/AppLogger.kt new file mode 100644 index 0000000..70b72c3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/example/project/util/AppLogger.kt @@ -0,0 +1,21 @@ +package org.example.project.util + +import android.util.Log + +actual object AppLogger { + actual fun e(tag: String, message: String, throwable: Throwable?) { + if (throwable != null) { + Log.e(tag, message, throwable) + } else { + Log.e(tag, message) + } + } + + actual fun d(tag: String, message: String) { + Log.d(tag, message) + } + + actual fun i(tag: String, message: String) { + Log.i(tag, message) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/selection_craftsman.png b/composeApp/src/commonMain/composeResources/drawable/selection_craftsman.png new file mode 100644 index 0000000..1cd6739 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/selection_craftsman.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/selection_customer.png b/composeApp/src/commonMain/composeResources/drawable/selection_customer.png new file mode 100644 index 0000000..8b43931 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/selection_customer.png differ diff --git a/composeApp/src/commonMain/composeResources/values-ar/string.xml b/composeApp/src/commonMain/composeResources/values-ar/string.xml index 0e01b6b..0dc7e89 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/string.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/string.xml @@ -39,5 +39,37 @@ السهم لأسفل + + How would you like to use Crafto? + What services do you offer? + Let’s personalize your profile + Show Us Your Work + Verify Your Identity\n(Optional) + I'll Verify Later + Next Step + + You can switch roles anytime from your profile. + Choose your specialties to get relevant job requests. You can change this later. + We’ll use this to personalize your experience. You can add a profile photo too, or skip for now. + Add photos of your past work. This helps build trust with customers. + Uploading your ID helps build trust with customers. Verified craftsmen get more jobs and a special badge on their profile. + + Upload front of national ID + Upload back of national ID + ID card image + + First name + Last name + Phone number + Address + + Describe your work (optional) + You can mention your years of experience, tools you use, or types of jobs you usually handle. + Tap to add photos + + Customer + I need help with a\n problem + Craftsman + I offer service \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/string.xml b/composeApp/src/commonMain/composeResources/values/string.xml index 2d14a09..a8033d4 100644 --- a/composeApp/src/commonMain/composeResources/values/string.xml +++ b/composeApp/src/commonMain/composeResources/values/string.xml @@ -48,6 +48,37 @@ back arrow + + How would you like to use Crafto? + What services do you offer? + Let’s personalize your profile + Show Us Your Work + Verify Your Identity\n(Optional) + I'll Verify Later + Next Step + You can switch roles anytime from your profile. + Choose your specialties to get relevant job requests. You can change this later. + We’ll use this to personalize your experience. You can add a profile photo too, or skip for now. + Add photos of your past work. This helps build trust with customers. + Uploading your ID helps build trust with customers. Verified craftsmen get more jobs and a special badge on their profile. + + Upload front of national ID + Upload back of national ID + ID card image + + First name + Last name + Phone number + Address + + Describe your work (optional) + You can mention your years of experience, tools you use, or types of jobs you usually handle. + Tap to add photos + + Customer + I need help with a\n problem + Craftsman + I offer service \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/App.kt b/composeApp/src/commonMain/kotlin/org/example/project/App.kt index 1236764..e767ad8 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/App.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/App.kt @@ -2,13 +2,16 @@ package org.example.project import androidx.compose.runtime.Composable import org.example.project.presentation.designsystem.textstyle.AppTheme -import org.example.project.presentation.screens.onboarding.OnboardingScreen +import org.example.project.presentation.screens.setup.craftsmansetup.CraftsmanSetupScreen +import org.example.project.presentation.screens.splash.SplashScreen import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @Preview fun App() { AppTheme { - OnboardingScreen() + //OnboardingScreen() + SplashScreen { } + CraftsmanSetupScreen() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/Greeting.kt b/composeApp/src/commonMain/kotlin/org/example/project/Greeting.kt deleted file mode 100644 index d49d319..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.example.project - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/local/datasource/CategoryDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/local/datasource/CategoryDataSourceImpl.kt new file mode 100644 index 0000000..3b64308 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/local/datasource/CategoryDataSourceImpl.kt @@ -0,0 +1,10 @@ +package org.example.project.data.local.datasource + +import org.example.project.data.remote.datasource.CategoryDataSource +import org.example.project.data.remote.dto.CategoryDto + +class CategoryMemoryDataSource( + private val seed: List +) : CategoryDataSource { + override suspend fun getCategories(): List = seed +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/local/datasource/StorageLocalDataSource.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/local/datasource/StorageLocalDataSource.kt new file mode 100644 index 0000000..d2ef580 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/local/datasource/StorageLocalDataSource.kt @@ -0,0 +1,15 @@ +package org.example.project.data.local.datasource + +interface StorageLocalDataSource { + suspend fun saveString(key: String, value: String) + suspend fun getString(key: String): String? + suspend fun saveStringSet(key: String, values: Set) + suspend fun getStringSet(key: String): Set? + suspend fun saveLong(key: String, value: Long) + suspend fun getLong(key: String): Long? + suspend fun saveBoolean(key: String, value: Boolean) + suspend fun getBoolean(key: String): Boolean? + suspend fun remove(key: String) + suspend fun removeAll() + suspend fun getAllKeys(): Set +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/CraftsmanMapper.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/CraftsmanMapper.kt new file mode 100644 index 0000000..1493cb8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/CraftsmanMapper.kt @@ -0,0 +1,76 @@ +package org.example.project.data.mapper + +import org.example.project.data.remote.dto.CategoryDto +import org.example.project.data.remote.dto.CraftsmanProfileResponseDto +import org.example.project.data.remote.dto.PersonalInfoDto +import org.example.project.data.remote.dto.VerificationInfoDto +import org.example.project.domain.entity.Category +import org.example.project.domain.entity.Craftsman +import org.example.project.domain.entity.CraftsmanStatus +import org.example.project.domain.entity.PersonalInfo +import org.example.project.domain.entity.VerificationDocuments +import org.example.project.domain.entity.VerificationStatus +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +fun CraftsmanProfileResponseDto.toDomain(): Craftsman { + return Craftsman( + craftsmanId = craftsmanId, + personalInfo = personalInfo.toDomain(), + categories = categories, + profilePictureUrl = profilePictureUrl, + status = CraftsmanStatus.valueOf(status), + verificationStatus = VerificationStatus.valueOf(verificationInfo.status), + verification = verificationInfo.toVerificationDocuments(), + createdAt = Instant.parse(createdAt) + ) +} + +fun VerificationInfoDto.toVerificationDocuments(): VerificationDocuments { + return VerificationDocuments( + idCardFrontUrl = idCardFrontUrl, + idCardBackUrl = idCardBackUrl, + workPortfolioUrls = workPortfolioUrls + ) +} + +fun PersonalInfoDto.toDomain(): PersonalInfo { + return PersonalInfo( + firstName = firstName, + lastName = lastName, + phoneNumber = phoneNumber, + address = address + ) +} + +fun PersonalInfo.toDto(): PersonalInfoDto { + return PersonalInfoDto( + firstName = firstName, + lastName = lastName, + phoneNumber = phoneNumber, + address = address + ) +} + +fun String.toCraftsmanStatus(): CraftsmanStatus { + return try { + CraftsmanStatus.valueOf(this) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid craftsman status: $this") + } +} + +fun String.toVerificationStatus(): VerificationStatus { + return try { + VerificationStatus.valueOf(this) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid verification status: $this") + } +} + +fun CategoryDto.toDomain(): Category = Category( + id = id, + title = title, + colorHex = colorHex +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/ExceptionMapper.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/ExceptionMapper.kt new file mode 100644 index 0000000..48cddd6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/ExceptionMapper.kt @@ -0,0 +1,47 @@ +package org.example.project.data.mapper + +import org.example.project.data.remote.dto.ErrorResponseDto +import org.example.project.domain.exception.* + +fun Exception.toDomainException(): CraftoException { + return when (this) { + is CraftoException -> this + is IllegalArgumentException -> ValidationException(message ?: "Invalid input") + is NoSuchElementException -> NotFoundException(message ?: "Resource not found") + is IllegalStateException -> this.toIllegalStateError() + else -> NetworkException(message ?: "Network error occurred") + } +} + +private fun IllegalStateException.toIllegalStateError(): CraftoException { + return if (isAuthenticationError()) { + UnauthorizedException(message?: "User not authenticated") + } else { + UnknownException(message ?: "Invalid state") + } +} + +private fun IllegalStateException.isAuthenticationError(): Boolean { + return message?.contains("auth", ignoreCase = true) == true +} + +fun ErrorResponseDto.toDomainException(): CraftoException { + return when (code) { + "CRAFTSMAN_EXISTS" -> AlreadyExistsException("Craftsman profile already exists") + "INVALID_INPUT" -> ValidationException(message) + "NOT_FOUND" -> NotFoundException(message) + "FORBIDDEN" -> ForbiddenException(message) + "UNAUTHORIZED" -> UnauthorizedException(message) + else -> UnknownException(message) + } +} + +suspend fun safeApiCall( + apiCall: suspend () -> T +): Result { + return try { + Result.success(apiCall()) + } catch (e: Exception) { + Result.failure(e.toDomainException() as Exception) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/OnboardingMapper.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/OnboardingMapper.kt index ce1da95..1a087bb 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/OnboardingMapper.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/OnboardingMapper.kt @@ -1,6 +1,6 @@ package org.example.project.data.mapper -import org.example.project.data.dto.OnboardingDto +import org.example.project.data.remote.dto.OnboardingDto import org.example.project.domain.entity.OnboardingItem fun OnboardingDto.toEntity() : OnboardingItem = diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/memory/categorySeed.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/memory/categorySeed.kt new file mode 100644 index 0000000..89cca62 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/memory/categorySeed.kt @@ -0,0 +1,20 @@ +package org.example.project.data.memory + +import org.example.project.data.remote.dto.CategoryDto + + +val categorySeed = listOf( + CategoryDto(1, "Plumbing", 0xFF9B59B6), + CategoryDto(2, "Electrical", 0xFF1ABC9C), + CategoryDto(3, "Cleaning", 0xFF3498DB), + CategoryDto(4, "AC Repair", 0xFFF39C12), + CategoryDto(5, "Furniture", 0xFFD35400), + CategoryDto(6, "Painting", 0xFF34495E), + CategoryDto(7, "Carpentry", 0xFFE67E22), + CategoryDto(8, "Roofing", 0xFF7F8C8D), + CategoryDto(9, "Landscaping", 0xFF2ECC71), + CategoryDto(10, "Pest Control", 0xFFC0392B), + CategoryDto(11, "Appliance Repair", 0xFF00BCD4), + CategoryDto(12, "Pool Maintenance", 0xFF8E44AD), + CategoryDto(13, "HVAC Maintenance", 0xFF27AE60), +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/memory/dataSource/CategoryDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/memory/dataSource/CategoryDataSourceImpl.kt deleted file mode 100644 index e3b4fc0..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/memory/dataSource/CategoryDataSourceImpl.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.example.project.data.memory.dataSource - -import org.example.project.data.repository.dataSource.CategoryDataSource -import org.example.project.data.repository.dataSource.memory.dto.CategoryEntity -import org.example.project.data.repository.mapper.toCategoryEntity -import org.example.project.domain.entity.Category -import org.koin.core.annotation.Provided -import org.koin.core.annotation.Single - -@Single(binds = [CategoryDataSource::class]) -class CategoryDataSourceImpl( - @Provided val categoryList: List -) : CategoryDataSource { - override suspend fun getCategories(): List { - return categoryList.map { it.toCategoryEntity() } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/memory/dataSource/categoryList.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/memory/dataSource/categoryList.kt deleted file mode 100644 index a49a25b..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/memory/dataSource/categoryList.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.example.project.data.memory.dataSource - -import androidx.compose.ui.graphics.Color -import org.example.project.domain.entity.Category - - -val categoryList = listOf( - Category( - id = 1, - title = "Plumbing", - isSelected = false, - color = Color(0xFF9B59B6), - ), - Category( - id = 2, - title = "Electrical", - isSelected = false, - color = Color(0xFF1ABC9C), - ), - Category( - id = 3, - title = "Cleaning", - isSelected = false, - color = Color(0xFF3498DB), - ), - Category( - id = 4, - title = "AC Repair", - isSelected = false, - color = Color(0xFFF39C12), - ), - Category( - id = 5, - title = "Furniture", - isSelected = false, - color = Color(0xFFD35400), - ), - Category( - id = 6, - title = "Painting", - isSelected = false, - color = Color(0xFF34495E), - ), - Category( - id = 7, - title = "Carpentry", - isSelected = false, - color = Color(0xFFE67E22), - ), - Category( - id = 8, - title = "Roofing", - isSelected = false, - color = Color(0xFF7F8C8D), - ), - Category( - id = 9, - title = "Landscaping", - isSelected = false, - color = Color(0xFF2ECC71), - ), - Category( - id = 10, - title = "Pest Control", - isSelected = false, - color = Color(0xFFC0392B), - ), - Category( - id = 11, - title = "Appliance Repair", - isSelected = false, - color = Color(0xFF00BCD4), - ), - Category( - id = 12, - title = "Pool Maintenance", - isSelected = false, - color = Color(0xFF8E44AD), - ), - Category( - id = 13, - title = "HVAC Maintenance", - isSelected = false, - color = Color(0xFF27AE60), - ), -) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CategoryDataSource.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CategoryDataSource.kt new file mode 100644 index 0000000..0478a1b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CategoryDataSource.kt @@ -0,0 +1,7 @@ +package org.example.project.data.remote.datasource + +import org.example.project.data.remote.dto.CategoryDto + +interface CategoryDataSource { + suspend fun getCategories(): List +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CraftsmanRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CraftsmanRemoteDataSource.kt new file mode 100644 index 0000000..0f3f932 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CraftsmanRemoteDataSource.kt @@ -0,0 +1,49 @@ +package org.example.project.data.remote.datasource + +import org.example.project.data.remote.dto.CraftsmanProfileResponseDto +import org.example.project.data.remote.dto.CraftsmanSetupResponseDto +import org.example.project.data.remote.dto.CraftsmanStatusResponseDto +import org.example.project.data.remote.dto.CreateCraftsmanRequest +import org.example.project.data.remote.dto.DeleteAccountResponseDto +import org.example.project.data.remote.dto.IdCardUploadResponseDto +import org.example.project.data.remote.dto.ProfilePictureUploadResponseDto +import org.example.project.data.remote.dto.WorkPortfolioResponseDto +import org.example.project.domain.model.WorkImage + +interface CraftsmanRemoteDataSource { + suspend fun createCraftsmanProfile( + userId: String, + request: CreateCraftsmanRequest + ): CraftsmanSetupResponseDto + + suspend fun uploadIdCards( + userId: String, + craftsmanId: String, + idCardFront: ByteArray, + idCardFrontFileName: String, + idCardBack: ByteArray, + idCardBackFileName: String + ): IdCardUploadResponseDto + + suspend fun uploadProfilePicture( + userId: String, + craftsmanId: String, + profilePicture: ByteArray, + profilePictureFileName: String + ): ProfilePictureUploadResponseDto + + suspend fun uploadWorkPortfolio( + userId: String, + craftsmanId: String, + workImages: List + ): WorkPortfolioResponseDto + + suspend fun getCraftsmanProfile(userId: String): CraftsmanProfileResponseDto + + suspend fun getCraftsmanStatus(craftsmanId: String): CraftsmanStatusResponseDto + + suspend fun deleteCraftsmanAccount( + userId: String, + craftsmanId: String + ): DeleteAccountResponseDto +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CraftsmanRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CraftsmanRemoteDataSourceImpl.kt new file mode 100644 index 0000000..4707fee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/datasource/CraftsmanRemoteDataSourceImpl.kt @@ -0,0 +1,159 @@ +package org.example.project.data.remote.datasource + +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import org.example.project.data.remote.dto.CraftsmanProfileResponseDto +import org.example.project.data.remote.dto.CraftsmanSetupResponseDto +import org.example.project.data.remote.dto.CraftsmanStatusResponseDto +import org.example.project.data.remote.dto.CreateCraftsmanRequest +import org.example.project.data.remote.dto.DeleteAccountResponseDto +import org.example.project.data.remote.dto.IdCardUploadResponseDto +import org.example.project.data.remote.dto.ProfilePictureUploadResponseDto +import org.example.project.data.remote.dto.WorkPortfolioResponseDto +import org.example.project.data.remote.network.ApiConstants +import org.example.project.data.remote.network.ApiConstants.Headers.USER_ID +import org.example.project.data.remote.network.wrapApiCall +import org.example.project.domain.model.WorkImage + +class CraftsmanRemoteDataSourceImpl( + private val httpClient: HttpClient, +) : CraftsmanRemoteDataSource { + + override suspend fun createCraftsmanProfile( + userId: String, + request: CreateCraftsmanRequest + ): CraftsmanSetupResponseDto { + return wrapApiCall { + httpClient.post(ApiConstants.Endpoints.CRAFTSMAN_SETUP) { + header(USER_ID, userId) + contentType(ContentType.Application.Json) + setBody(request) + } + } + } + + override suspend fun uploadIdCards( + userId: String, + craftsmanId: String, + idCardFront: ByteArray, + idCardFrontFileName: String, + idCardBack: ByteArray, + idCardBackFileName: String + ): IdCardUploadResponseDto { + return wrapApiCall { + httpClient.submitFormWithBinaryData( + url = ApiConstants.Endpoints.craftsmanIdCards(craftsmanId), + formData = formData { + val frontMimeType = getMimeType(idCardFrontFileName) + + append("idCardFront", idCardFront, Headers.build { + append(HttpHeaders.ContentType, frontMimeType) + append(HttpHeaders.ContentDisposition, "filename=\"$idCardFrontFileName\"") + }) + + val backMimeType = getMimeType(idCardBackFileName) + + append("idCardBack", idCardBack, Headers.build { + append(HttpHeaders.ContentType, backMimeType) + append(HttpHeaders.ContentDisposition, "filename=\"$idCardBackFileName\"") + }) + } + ) { + header(USER_ID, userId) + } + } + } + + override suspend fun uploadProfilePicture( + userId: String, + craftsmanId: String, + profilePicture: ByteArray, + profilePictureFileName: String + ): ProfilePictureUploadResponseDto { + return wrapApiCall { + httpClient.submitFormWithBinaryData( + url = ApiConstants.Endpoints.craftsmanProfilePicture(craftsmanId), + formData = formData { + val mimeType = getMimeType(profilePictureFileName) + + append("profilePicture", profilePicture, Headers.build { + append(HttpHeaders.ContentType, mimeType) + append(HttpHeaders.ContentDisposition, "filename=\"$profilePictureFileName\"") + }) + } + ) { + header(USER_ID, userId) + } + } + } + + override suspend fun uploadWorkPortfolio( + userId: String, + craftsmanId: String, + workImages: List + ): WorkPortfolioResponseDto { + return wrapApiCall { + httpClient.submitFormWithBinaryData( + url = ApiConstants.Endpoints.craftsmanWorkPortfolio(craftsmanId), + formData = formData { + workImages.forEachIndexed { index, image -> + val mimeType = getMimeType(image.fileName) + append( + key = "workImages", + value = image.data, + headers = Headers.build { + append(HttpHeaders.ContentType, mimeType) + append(HttpHeaders.ContentDisposition, "filename=\"${image.fileName}\"") + } + ) + } + } + ) { + header(USER_ID, userId) + } + } + } + + override suspend fun getCraftsmanProfile(userId: String): CraftsmanProfileResponseDto { + return wrapApiCall { + httpClient.get(ApiConstants.Endpoints.CRAFTSMAN_PROFILE) { + header(USER_ID, userId) + } + } + } + + override suspend fun getCraftsmanStatus(craftsmanId: String): CraftsmanStatusResponseDto { + return wrapApiCall { + httpClient.get(ApiConstants.Endpoints.craftsmanStatus(craftsmanId)) + } + } + + override suspend fun deleteCraftsmanAccount( + userId: String, + craftsmanId: String + ): DeleteAccountResponseDto { + return wrapApiCall { + httpClient.delete(ApiConstants.Endpoints.deleteCraftsman(craftsmanId)) { + header(USER_ID, userId) + } + } + } + + private fun getMimeType(fileName: String): String { + return when (fileName.substringAfterLast('.', "").lowercase()) { + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + else -> "image/jpeg" // Default to JPEG + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/CraftsmanDto.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/CraftsmanDto.kt new file mode 100644 index 0000000..9d68c70 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/CraftsmanDto.kt @@ -0,0 +1,96 @@ +package org.example.project.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateCraftsmanRequest( + val personalInfo: PersonalInfoDto, + val categories: List +) + +@Serializable +data class CraftsmanSetupResponseDto( + val craftsmanId: String, + val status: String, + val message: String +) + +@Serializable +data class IdCardUploadResponseDto( + val craftsmanId: String, + val idCardFrontUrl: String, + val idCardBackUrl: String, + val message: String, + val idVerificationStatus: String +) + +@Serializable +data class WorkPortfolioResponseDto( + val craftsmanId: String, + val workImageUrls: List, + val message: String, + val totalImages: Int +) + +@Serializable +data class CraftsmanProfileResponseDto( + val craftsmanId: String, + val personalInfo: PersonalInfoDto, + val categories: List, + val profilePictureUrl: String? = null, + val status: String, + val verificationInfo: VerificationInfoDto, + val createdAt: String +) + +@Serializable +data class CraftsmanStatusResponseDto( + val craftsmanId: String, + val status: String, + val profilePictureUrl: String? = null, + val verificationStatus: String, + val message: String +) + +@Serializable +data class ProfilePictureUploadResponseDto( + val craftsmanId: String, + val profilePictureUrl: String, + val message: String +) + +@Serializable +data class DeleteAccountResponseDto( + val success: Boolean, + val message: String +) + +@Serializable +data class PersonalInfoDto( + val firstName: String, + val lastName: String, + val phoneNumber: String, + val address: String +) + +@Serializable +data class VerificationInfoDto( + val status: String, + val idCardFrontUrl: String?, + val idCardBackUrl: String?, + val workPortfolioUrls: List +) + +@Serializable +data class ErrorResponseDto( + val code: String, + val message: String, + val timestamp: String +) + + +data class CategoryDto( + val id: Int, + val title: String, + val colorHex: Long +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/dto/DistrictDto.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/DistrictDto.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/org/example/project/data/dto/DistrictDto.kt rename to composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/DistrictDto.kt diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/dto/GovernoratesDto.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/GovernoratesDto.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/org/example/project/data/dto/GovernoratesDto.kt rename to composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/GovernoratesDto.kt diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/dto/OnboardingDto.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/OnboardingDto.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/org/example/project/data/dto/OnboardingDto.kt rename to composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/OnboardingDto.kt index 3b355fa..c39a314 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/dto/OnboardingDto.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/dto/OnboardingDto.kt @@ -1,4 +1,4 @@ -package org.example.project.data.dto +package org.example.project.data.remote.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -13,4 +13,4 @@ data class OnboardingDto( val imageUrl : String, @SerialName("description") val description : String -) +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/APIConstant.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/APIConstant.kt new file mode 100644 index 0000000..56e6ebc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/APIConstant.kt @@ -0,0 +1,35 @@ +package org.example.project.data.remote.network + +object ApiConstants { + const val BASE_URL = "http://192.168.1.51:8085" + + // Headers + object Headers { + const val USER_ID = "userId" + const val AUTH_TOKEN = "Authorization" + const val CONTENT_TYPE = "Content-Type" + const val ACCEPT = "Accept" + } + + // Timeouts (in milliseconds) + object Timeouts { + const val REQUEST = 30_000L // 30 seconds + const val CONNECT = 10_000L // 10 seconds + const val SOCKET = 30_000L // 30 seconds + } + + // API Endpoints + object Endpoints { + const val CRAFTSMAN_SETUP = "/craftsman/setup" + const val CRAFTSMAN_PROFILE = "/craftsman/profile" + const val ONBOARDING_END_POINT = "/onboarding" + + fun craftsmanProfilePicture(craftsmanId: String) = "/craftsman/$craftsmanId/profile-picture" + fun craftsmanIdCards(craftsmanId: String) = "/craftsman/$craftsmanId/verify/id-cards" + fun craftsmanWorkPortfolio(craftsmanId: String) = "/craftsman/$craftsmanId/verify/work-portfolio" + fun craftsmanStatus(craftsmanId: String) = "/craftsman/$craftsmanId/status" + fun deleteCraftsman(craftsmanId: String) = "/craftsman/$craftsmanId" + + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/ApiCallWrapper.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/ApiCallWrapper.kt new file mode 100644 index 0000000..461efae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/ApiCallWrapper.kt @@ -0,0 +1,72 @@ +package org.example.project.data.remote.network + +import io.ktor.client.call.body +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode +import org.example.project.data.remote.dto.ErrorResponseDto +import org.example.project.domain.exception.AlreadyExistsException +import org.example.project.domain.exception.ApiException +import org.example.project.domain.exception.CraftoException +import org.example.project.domain.exception.ForbiddenException +import org.example.project.domain.exception.NetworkException +import org.example.project.domain.exception.NotFoundException +import org.example.project.domain.exception.ServerUnavailableException +import org.example.project.domain.exception.UnauthorizedException + +suspend inline fun wrapApiCall( + apiCall: suspend () -> HttpResponse +): T { + try { + val response = apiCall() + + return when (response.status) { + HttpStatusCode.OK, HttpStatusCode.Created -> { + response.body() + } + HttpStatusCode.Unauthorized -> { + throw UnauthorizedException("Authentication required") + } + HttpStatusCode.Forbidden -> { + throw ForbiddenException("Access denied") + } + HttpStatusCode.NotFound -> { + throw NotFoundException("Resource not found") + } + HttpStatusCode.Conflict -> { + val error = try { + response.body() + } catch (e: Exception) { + null + } + throw AlreadyExistsException(error?.message ?: "Resource already exists") + } + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout -> { + throw ServerUnavailableException("Server is experiencing issues. Please try again later.") + } + else -> { + val error = try { + response.body() + } catch (e: Exception) { + null + } + throw ApiException(error?.message ?: "API error: ${response.status.value}") + } + } + } catch (e: CraftoException) { + throw e + } catch (e: ConnectTimeoutException) { + throw ServerUnavailableException("Cannot connect to server. Please try again later.") + } catch (e: SocketTimeoutException) { + throw NetworkException("Connection timeout. Please check your internet connection.") + } catch (e: HttpRequestTimeoutException) { + throw NetworkException("Request timeout. Please try again.") + }catch (e: Exception) { + throw NetworkException("Network error: ${e.message}") + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/HttpClientFactory.kt new file mode 100644 index 0000000..cc2285d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/remote/network/HttpClientFactory.kt @@ -0,0 +1,44 @@ +package org.example.project.data.remote.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +fun createHttpClient(engine: HttpClientEngine): HttpClient { + return HttpClient(engine) { + install(ContentNegotiation) { + json(Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + encodeDefaults = true + }) + } + + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + + install(HttpTimeout) { + requestTimeoutMillis = ApiConstants.Timeouts.REQUEST + connectTimeoutMillis = ApiConstants.Timeouts.CONNECT + socketTimeoutMillis = ApiConstants.Timeouts.SOCKET + } + + defaultRequest { + url(ApiConstants.BASE_URL) + header(ApiConstants.Headers.ACCEPT, ContentType.Application.Json) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CategoryRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CategoryRepositoryImpl.kt index 4ed14b3..b4832a4 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CategoryRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CategoryRepositoryImpl.kt @@ -1,17 +1,15 @@ package org.example.project.data.repository -import org.example.project.data.repository.dataSource.CategoryDataSource -import org.example.project.data.repository.mapper.toCategoryDomain + +import org.example.project.data.remote.datasource.CategoryDataSource +import org.example.project.data.mapper.toDomain import org.example.project.domain.entity.Category import org.example.project.domain.repository.CategoryRepository -import org.koin.core.annotation.Provided -import org.koin.core.annotation.Single -@Single(binds = [CategoryRepository::class]) + class CategoryRepositoryImpl( - @Provided val dataSource: CategoryDataSource + private val dataSource: CategoryDataSource ) : CategoryRepository { - override suspend fun getCategories(): List { - return dataSource.getCategories().map { it.toCategoryDomain() } - } + override suspend fun getCategories(): List = + dataSource.getCategories().map { it.toDomain() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CraftsmanRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CraftsmanRepositoryImpl.kt new file mode 100644 index 0000000..ab1c950 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/CraftsmanRepositoryImpl.kt @@ -0,0 +1,137 @@ +package org.example.project.data.repository + +import org.example.project.data.remote.dto.CreateCraftsmanRequest +import org.example.project.domain.repository.UserPreferences +import org.example.project.data.mapper.toDomain +import org.example.project.data.mapper.toDto +import org.example.project.data.remote.datasource.CraftsmanRemoteDataSource +import org.example.project.domain.entity.Craftsman +import org.example.project.domain.entity.CraftsmanStatus +import org.example.project.domain.entity.PersonalInfo +import org.example.project.domain.entity.VerificationDocuments +import org.example.project.domain.exception.ApiException +import org.example.project.domain.exception.UnauthorizedException +import org.example.project.domain.model.WorkImage +import org.example.project.domain.repository.CraftsmanRepository +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class CraftsmanRepositoryImpl ( + private val remoteDataSource: CraftsmanRemoteDataSource, + private val userPreferences: UserPreferences +) : CraftsmanRepository { + @OptIn(ExperimentalTime::class) + override suspend fun createCraftsmanProfile( + personalInfo: PersonalInfo, + categories: List + ): String { + var userId = userPreferences.getUserId() + //?: throw UnauthorizedException("Session must be created to create craftsman profile") + + if (userId.isNullOrBlank()) { + userId = "temp-user-${Clock.System.now()}" + println("⚠️ Using temporary userId: $userId (remove after OTP integration)") + userPreferences.setUserId(userId) + } + + val request = CreateCraftsmanRequest( + personalInfo = personalInfo.toDto(), + categories = categories + ) + + val response = remoteDataSource.createCraftsmanProfile(userId, request) + + return response.craftsmanId + } + + override suspend fun uploadIdCards( + craftsmanId: String, + idCardFront: ByteArray, + idCardFrontFileName: String, + idCardBack: ByteArray, + idCardBackFileName: String + ): VerificationDocuments { + val userId = userPreferences.getUserId() + ?: throw UnauthorizedException("User must be logged in to upload documents") + + val response = remoteDataSource.uploadIdCards( + userId = userId, + craftsmanId = craftsmanId, + idCardFront = idCardFront, + idCardFrontFileName = idCardFrontFileName, + idCardBack = idCardBack, + idCardBackFileName = idCardBackFileName + ) + + return VerificationDocuments( + idCardFrontUrl = response.idCardFrontUrl, + idCardBackUrl = response.idCardBackUrl, + workPortfolioUrls = emptyList() + ) + } + + override suspend fun uploadProfilePicture( + craftsmanId: String, + profilePicture: ByteArray, + profilePictureFileName: String + ): String { + val userId = userPreferences.getUserId() + ?: throw UnauthorizedException("User must be logged in to upload profile picture") + + val response = remoteDataSource.uploadProfilePicture( + userId = userId, + craftsmanId = craftsmanId, + profilePicture = profilePicture, + profilePictureFileName = profilePictureFileName + ) + return response.profilePictureUrl + } + + override suspend fun uploadWorkPortfolio( + craftsmanId: String, + workImages: List + ): List { + val userId = userPreferences.getUserId() + ?: throw UnauthorizedException() + + val response = remoteDataSource.uploadWorkPortfolio( + userId, + craftsmanId, + workImages + ) + + return response.workImageUrls + } + + override suspend fun getCraftsmanProfile(): Craftsman { + val userId = userPreferences.getUserId() + ?: throw UnauthorizedException() + + val response = remoteDataSource.getCraftsmanProfile(userId) + return response.toDomain() + } + + override suspend fun getCraftsmanStatus(craftsmanId: String): CraftsmanStatus { + val response = remoteDataSource.getCraftsmanStatus(craftsmanId) + return try { + CraftsmanStatus.valueOf(response.status) + } catch ( + e: IllegalArgumentException) { + throw ApiException("Invalid craftsman status: ${response.status}") + } + } + + override suspend fun deleteCraftsmanAccount(craftsmanId: String) { + + val userId = userPreferences.getUserId() + ?: throw UnauthorizedException() + + val response = remoteDataSource.deleteCraftsmanAccount(userId, craftsmanId) + + if (!response.success) { + throw ApiException(response.message) + } + userPreferences.clearUserId() + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/OnboardingRepositoryImp.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/OnboardingRepositoryImp.kt index 84365bf..b4ec49b 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/OnboardingRepositoryImp.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/OnboardingRepositoryImp.kt @@ -2,10 +2,10 @@ package org.example.project.data.repository import io.ktor.client.HttpClient import io.ktor.client.request.get -import org.example.project.data.dto.OnboardingDto +import org.example.project.data.remote.dto.OnboardingDto import org.example.project.data.mapper.toEntity -import org.example.project.data.utils.NetworkConstants.ONBOARDING_END_POINT -import org.example.project.data.utils.safeApiCall +import org.example.project.data.remote.network.ApiConstants.Endpoints.ONBOARDING_END_POINT +import org.example.project.data.remote.network.wrapApiCall import org.example.project.domain.entity.OnboardingItem import org.example.project.domain.repository.OnboardingRepository import org.koin.core.annotation.Provided @@ -18,7 +18,7 @@ class OnboardingRepositoryImp( ) : OnboardingRepository { override suspend fun getOnboardingData(): List { - return safeApiCall> { + return wrapApiCall> { httpClient.get(ONBOARDING_END_POINT) }.map { it.toEntity() } } diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/UserPreferencesImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/UserPreferencesImpl.kt new file mode 100644 index 0000000..aec8671 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/UserPreferencesImpl.kt @@ -0,0 +1,25 @@ +package org.example.project.data.repository + +import org.example.project.data.local.datasource.StorageLocalDataSource +import org.example.project.domain.repository.UserPreferences + +class UserPreferencesImpl( + private val storage: StorageLocalDataSource +) : UserPreferences { + + companion object { + private const val KEY_USER_ID = "user_id" + } + + override suspend fun getUserId(): String? { + return storage.getString(KEY_USER_ID) + } + + override suspend fun setUserId(userId: String) { + storage.saveString(KEY_USER_ID, userId) + } + + override suspend fun clearUserId() { + storage.remove(KEY_USER_ID) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/dataSource/CategoryDataSource.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/dataSource/CategoryDataSource.kt deleted file mode 100644 index 566e52f..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/dataSource/CategoryDataSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.example.project.data.repository.dataSource - -import org.example.project.data.repository.dataSource.memory.dto.CategoryEntity - -interface CategoryDataSource { - suspend fun getCategories(): List -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/dataSource/memory/dto/CategoryEntity.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/dataSource/memory/dto/CategoryEntity.kt deleted file mode 100644 index 340c029..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/dataSource/memory/dto/CategoryEntity.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.example.project.data.repository.dataSource.memory.dto - -import androidx.compose.ui.graphics.Color - -data class CategoryEntity( - val id: Int, - val title: String, - val isSelected: Boolean, - val color: Color, -) diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/mapper/Category.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/mapper/Category.kt deleted file mode 100644 index a72d3f3..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/repository/mapper/Category.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.example.project.data.repository.mapper - -import org.example.project.data.repository.dataSource.memory.dto.CategoryEntity -import org.example.project.domain.entity.Category - -fun Category.toCategoryEntity(): CategoryEntity { - return CategoryEntity( - id = id, - title = title, - isSelected = isSelected, - color = color, - ) -} - -fun CategoryEntity.toCategoryDomain(): Category { - return Category( - id = id, - title = title, - isSelected = isSelected, - color = color, - ) -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/service/ValidationServiceImpl.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/service/ValidationServiceImpl.kt new file mode 100644 index 0000000..a7b8e8a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/service/ValidationServiceImpl.kt @@ -0,0 +1,23 @@ +package org.example.project.data.service + +import org.example.project.domain.service.ValidationService +import org.example.project.domain.util.AppConstants + +class ValidationServiceImpl : ValidationService { + override fun isValidPhoneNumber(phone: String): Boolean { + return phone.matches(Regex(AppConstants.PersonalInfo.PHONE_REGEX)) + } + + override fun isValidEmail(email: String): Boolean { + return email.matches(Regex(AppConstants.PersonalInfo.EMAIL_REGEX)) + } + + override fun isValidImageFileName(fileName: String): Boolean { + val extension = fileName.substringAfterLast('.', "").lowercase() + return extension in AppConstants.FileUpload.ALLOWED_IMAGE_TYPES + } + + override fun isValidFileSize(sizeInBytes: Int): Boolean { + return sizeInBytes <= AppConstants.FileUpload.MAX_FILE_SIZE + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/utils/safeApiCall.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/utils/safeApiCall.kt deleted file mode 100644 index f0a034f..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/data/utils/safeApiCall.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.example.project.data.utils - -import io.ktor.client.call.body -import io.ktor.client.statement.HttpResponse -import io.ktor.http.HttpStatusCode -import io.ktor.util.network.UnresolvedAddressException -import kotlinx.io.IOException - -internal suspend inline fun safeApiCall( - execute: suspend () -> HttpResponse -): T { - val result = try { - execute() - } catch (exception: IOException) { - logError(SAFE_API_CALL_TAG, "IOException", exception.message.toString()) - - } catch (exception: UnresolvedAddressException) { - logError(SAFE_API_CALL_TAG, "UnresolvedAddressException", exception.message.toString()) - } catch (exception: Exception) { - logError(SAFE_API_CALL_TAG, "Unknown exception", exception.message.toString()) - } - - return handleResponseStatusCode(result as HttpResponse) -} - -private suspend inline fun handleResponseStatusCode(result: HttpResponse): T { - return when (result.status.value) { - in 200..299 -> { - result.body() - } - - in 400..499 -> { - when (result.status) { - HttpStatusCode.Unauthorized -> { - logError( - HANDLE_ERROR_STATUS_TAG, - "Unauthorized", - "Not authorized to do this action" - ) - throw Exception() - } - - HttpStatusCode.NotFound -> { - logError( - HANDLE_ERROR_STATUS_TAG, - "Not found", - "the resource you requested could not be found" - ) - throw Exception() - } - - else -> { - logError( - HANDLE_ERROR_STATUS_TAG, - "Unknown 400s status code ${result.status.value}", - "An error with status code ${result.status.value} happened" - ) - throw Exception() - } - } - } - - in 500..599 -> { - logError( - HANDLE_ERROR_STATUS_TAG, - "Server error", - "An error occurred on the server side" - ) - throw Exception() - } - - else -> { - logError( - HANDLE_ERROR_STATUS_TAG, - "Unknown status code ${result.status.value}", - "An error with status code ${result.status.value} happened" - ) - throw Exception() - } - } -} - -private fun logError( - tag: String, - type: String, - message: String -) { - println("$tag----------- : $type $message") -} - -private const val SAFE_API_CALL_TAG = "safeApiCall" -private const val HANDLE_ERROR_STATUS_TAG = "handleErrorStatus" \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/CraftoModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/CraftoModule.kt deleted file mode 100644 index 91df6ba..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/di/CraftoModule.kt +++ /dev/null @@ -1,11 +0,0 @@ -import org.example.project.data.repository.LocationRepositoryImpl -import org.example.project.domain.repository.LocationRepository -import org.koin.core.annotation.Module -import org.koin.dsl.module - -@Module -class CraftoModule { - val module = module { - single { LocationRepositoryImpl(get()) } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/DataModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/DataModule.kt new file mode 100644 index 0000000..4ab09b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/DataModule.kt @@ -0,0 +1,36 @@ +package org.example.project.di + + +import org.example.project.domain.repository.UserPreferences +import org.example.project.data.remote.datasource.CategoryDataSource +import org.example.project.data.remote.datasource.CraftsmanRemoteDataSource +import org.example.project.data.local.datasource.CategoryMemoryDataSource +import org.example.project.data.repository.UserPreferencesImpl +import org.example.project.data.memory.categorySeed +import org.example.project.data.remote.datasource.CraftsmanRemoteDataSourceImpl +import org.example.project.data.repository.CategoryRepositoryImpl +import org.example.project.data.repository.CraftsmanRepositoryImpl +import org.example.project.data.repository.LocationRepositoryImpl +import org.example.project.data.service.ValidationServiceImpl +import org.example.project.domain.repository.CategoryRepository +import org.example.project.domain.repository.CraftsmanRepository +import org.example.project.domain.repository.LocationRepository +import org.example.project.domain.service.ValidationService +import org.koin.dsl.module + +val dataModule = module { + single { UserPreferencesImpl(get()) } + single { CraftsmanRemoteDataSourceImpl(get()) } + single { + CraftsmanRepositoryImpl( + remoteDataSource = get(), + userPreferences = get() + ) + } + single { categorySeed } + single { CategoryMemoryDataSource(get()) } + single { CategoryRepositoryImpl(get()) } + single { ValidationServiceImpl() } + single { LocationRepositoryImpl(get()) } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/DomainModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/DomainModule.kt new file mode 100644 index 0000000..70c9a8c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/DomainModule.kt @@ -0,0 +1,22 @@ +package org.example.project.di + +import org.example.project.domain.usecase.GetCategoriesUseCase +import org.example.project.domain.usecase.craftsman.CreateCraftsmanProfileUseCase +import org.example.project.domain.usecase.craftsman.DeleteCraftsmanAccountUseCase +import org.example.project.domain.usecase.craftsman.GetCraftsmanProfileUseCase +import org.example.project.domain.usecase.craftsman.GetCraftsmanStatusUseCase +import org.example.project.domain.usecase.craftsman.UploadIdCardsUseCase +import org.example.project.domain.usecase.craftsman.UploadProfilePictureUseCase +import org.example.project.domain.usecase.craftsman.UploadWorkPortfolioUseCase +import org.koin.dsl.module + +val domainModule = module { + factory { CreateCraftsmanProfileUseCase(get(), get()) } + factory { UploadIdCardsUseCase(get(),get()) } + factory { UploadWorkPortfolioUseCase(get()) } + factory { GetCraftsmanProfileUseCase(get()) } + factory { GetCraftsmanStatusUseCase(get()) } + factory { DeleteCraftsmanAccountUseCase(get()) } + factory { GetCategoriesUseCase(get())} + factory { UploadProfilePictureUseCase(get(),get()) } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/NetworkModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/NetworkModule.kt index 110d1ba..d8c8cce 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/di/NetworkModule.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/NetworkModule.kt @@ -1,53 +1,9 @@ package org.example.project.di -import io.ktor.client.HttpClient -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.plugins.logging.Logging -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single +import org.example.project.data.remote.network.createHttpClient +import org.koin.dsl.module -//192.168.1.15 -@Module -class NetworkModule { - @Single - fun provideHttpClient(): HttpClient { - return HttpClient(engine = getHttpEngine()) { - defaultRequest { - url("http://10.0.2.2:8085/") - } - - install(Logging) { - level = LogLevel.ALL - logger = object : Logger { - override fun log(message: String) { - println(message) - } - } - } - - install(HttpTimeout) { - connectTimeoutMillis = TIME_OUT_INTERVAL_MILLI - requestTimeoutMillis = TIME_OUT_INTERVAL_MILLI - } - - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - } - ) - } - } - } - - private companion object { - const val TIME_OUT_INTERVAL_MILLI = 10_000L - } +val networkModule = module { + single { getHttpEngine() } + single { createHttpClient(get()) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/PresentationModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/PresentationModule.kt new file mode 100644 index 0000000..9a1adec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/PresentationModule.kt @@ -0,0 +1,19 @@ +package org.example.project.di + +import org.example.project.presentation.screens.setup.craftsmansetup.CraftsmanSetupViewModel +import org.example.project.presentation.screens.setup.location.LocationViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val presentationModule = module { + viewModel { + CraftsmanSetupViewModel( + createCraftsmanUseCase = get(), + uploadIdCardsUseCase = get(), + uploadWorkPortfolioUseCase = get(), + getCategoriesUseCase = get(), + uploadProfilePictureUseCase = get(), + ) + } + viewModel {LocationViewModel(get())} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/ViewModelModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/ViewModelModule.kt deleted file mode 100644 index 25a13a5..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/di/ViewModelModule.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.example.project.di - -import org.example.project.presentation.screens.setupScreens.location.LocationViewModel -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -class ViewModelModule { - val module = module { - viewModel { LocationViewModel(get()) } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/initKoin.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/initKoin.kt index c6b2ef1..e38cf21 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/di/initKoin.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/initKoin.kt @@ -1,16 +1,18 @@ -import org.example.project.di.NetworkModule -import org.example.project.di.ViewModelModule +package org.example.project.di + import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration -import org.koin.ksp.generated.module + fun initKoin(config: KoinAppDeclaration? = null) { startKoin { config?.invoke(this) modules( - CraftoModule().module, - ViewModelModule().module, - NetworkModule().module + //CraftoModule().module, + networkModule, + dataModule, + domainModule, + presentationModule ) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Category.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Category.kt index dbb4dde..a9c3f10 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Category.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Category.kt @@ -1,10 +1,7 @@ package org.example.project.domain.entity -import androidx.compose.ui.graphics.Color - data class Category( val id: Int, val title: String, - val isSelected: Boolean, - val color: Color, + val colorHex: Long, ) diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Craftsman.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Craftsman.kt new file mode 100644 index 0000000..0eb90b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/Craftsman.kt @@ -0,0 +1,58 @@ +package org.example.project.domain.entity + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +data class Craftsman @OptIn(ExperimentalTime::class) constructor( + val craftsmanId: String, + val personalInfo: PersonalInfo, + val profilePictureUrl: String? = null, + val categories: List, + val status: CraftsmanStatus, + val verificationStatus: VerificationStatus, + val verification: VerificationDocuments, + val createdAt: Instant, +){ + fun isVerified(): Boolean = verificationStatus == VerificationStatus.VERIFIED + fun canReceiveJobs(): Boolean = status == CraftsmanStatus.ACTIVE && isVerified() + fun isProfileComplete(): Boolean = hasRequiredDocuments() && personalInfo.isComplete() + + private fun hasRequiredDocuments(): Boolean { + return verification.idCardFrontUrl != null && + verification.idCardBackUrl != null && + verification.workPortfolioUrls.isNotEmpty() + } + + private fun PersonalInfo.isComplete(): Boolean { + return firstName.isNotEmpty() && + lastName.isNotEmpty() && + address.isNotEmpty() + } +} + +data class PersonalInfo( + val firstName: String, + val lastName: String, + val phoneNumber: String, + val address: String +) + +data class VerificationDocuments( + val idCardFrontUrl: String? = null, + val idCardBackUrl: String? = null, + val workPortfolioUrls: List = emptyList() +) + +enum class CraftsmanStatus { + PENDING_VERIFICATION, + ACTIVE, + SUSPENDED, + REJECTED +} + +enum class VerificationStatus { + NOT_SUBMITTED, + PENDING, + VERIFIED, + REJECTED +} diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/exception/DomainExceptions.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/exception/DomainExceptions.kt new file mode 100644 index 0000000..a1efd43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/exception/DomainExceptions.kt @@ -0,0 +1,13 @@ +package org.example.project.domain.exception + +sealed class CraftoException(message: String?) : Exception(message) + +class UnauthorizedException(message: String? = "User not authenticated") : CraftoException(message) +class ForbiddenException(message: String? = "Access denied") : CraftoException(message) +class NotFoundException(message: String?) : CraftoException(message) +class AlreadyExistsException(message: String?) : CraftoException(message) +class ValidationException(message: String?) : CraftoException(message) +class NetworkException(message: String? = "Network error occurred") : CraftoException(message) +class ApiException(message: String?) : CraftoException(message) +class ServerUnavailableException(message: String? = "Server is currently unavailable") : CraftoException(message) // NEW +class UnknownException(message: String? = "Unknown error occurred") : CraftoException(message) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/model/WorkImage.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/model/WorkImage.kt new file mode 100644 index 0000000..bf23f83 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/model/WorkImage.kt @@ -0,0 +1,24 @@ +package org.example.project.domain.model + +data class WorkImage( + val data: ByteArray, + val fileName: String +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as WorkImage + + if (!data.contentEquals(other.data)) return false + if (fileName != other.fileName) return false + + return true + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + fileName.hashCode() + return result + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/CraftsmanRepository.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/CraftsmanRepository.kt new file mode 100644 index 0000000..2c44095 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/CraftsmanRepository.kt @@ -0,0 +1,39 @@ +package org.example.project.domain.repository + +import org.example.project.domain.entity.Craftsman +import org.example.project.domain.entity.CraftsmanStatus +import org.example.project.domain.entity.PersonalInfo +import org.example.project.domain.entity.VerificationDocuments +import org.example.project.domain.model.WorkImage + +interface CraftsmanRepository { + suspend fun createCraftsmanProfile( + personalInfo: PersonalInfo, + categories: List + ): String + + suspend fun uploadIdCards( + craftsmanId: String, + idCardFront: ByteArray, + idCardFrontFileName: String, + idCardBack: ByteArray, + idCardBackFileName: String + ): VerificationDocuments + + suspend fun uploadProfilePicture( + craftsmanId: String, + profilePicture: ByteArray, + profilePictureFileName: String + ): String + + suspend fun uploadWorkPortfolio( + craftsmanId: String, + workImages: List + ): List + + suspend fun getCraftsmanProfile(): Craftsman + + suspend fun getCraftsmanStatus(craftsmanId: String): CraftsmanStatus + + suspend fun deleteCraftsmanAccount(craftsmanId: String) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/UserPreferences.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/UserPreferences.kt new file mode 100644 index 0000000..7564fd0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/UserPreferences.kt @@ -0,0 +1,7 @@ +package org.example.project.domain.repository + +interface UserPreferences { + suspend fun getUserId(): String? + suspend fun setUserId(userId: String) + suspend fun clearUserId() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/service/ValidationService.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/service/ValidationService.kt new file mode 100644 index 0000000..b2bda9b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/service/ValidationService.kt @@ -0,0 +1,8 @@ +package org.example.project.domain.service + +interface ValidationService { + fun isValidPhoneNumber(phone: String): Boolean + fun isValidEmail(email: String): Boolean + fun isValidImageFileName(fileName: String): Boolean + fun isValidFileSize(sizeInBytes: Int): Boolean +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/GetCategoriesUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/GetCategoriesUseCase.kt index 1c5536f..634a537 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/GetCategoriesUseCase.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/GetCategoriesUseCase.kt @@ -2,12 +2,12 @@ package org.example.project.domain.usecase import org.example.project.domain.entity.Category import org.example.project.domain.repository.CategoryRepository +import org.koin.core.annotation.Factory import org.koin.core.annotation.Provided -import org.koin.core.annotation.Single -@Single + class GetCategoriesUseCase( - @Provided val repository: CategoryRepository) { + val repository: CategoryRepository) { suspend operator fun invoke(): List { return repository.getCategories() } diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/CreateCraftsmanProfileUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/CreateCraftsmanProfileUseCase.kt new file mode 100644 index 0000000..dcddf9a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/CreateCraftsmanProfileUseCase.kt @@ -0,0 +1,49 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.entity.PersonalInfo +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.repository.CraftsmanRepository +import org.example.project.domain.service.ValidationService +import org.example.project.domain.util.AppConstants +import org.example.project.domain.util.AppConstants.Categories.MAX_CATEGORIES + + +class CreateCraftsmanProfileUseCase( + private val repository: CraftsmanRepository, + private val validationService: ValidationService +) { + suspend operator fun invoke( + personalInfo: PersonalInfo, + categories: List + ): String { + if (categories.size < AppConstants.Categories.MIN_CATEGORIES) { + throw ValidationException("Please select at least one service category") + } + + if (categories.size > MAX_CATEGORIES) { + throw ValidationException("Please select at most $MAX_CATEGORIES service categories") + } + + if (personalInfo.firstName.length < AppConstants.PersonalInfo.MIN_FIRST_NAME_LENGTH) { + throw ValidationException("First name is required") + } + + if (personalInfo.lastName.length < AppConstants.PersonalInfo.MIN_LAST_NAME_LENGTH) { + throw ValidationException("Last name is required") + } + + if (personalInfo.phoneNumber.isBlank()) { + throw ValidationException("Phone number is required") + } + + if (!validationService.isValidPhoneNumber(personalInfo.phoneNumber)) { + throw ValidationException("Please enter a valid phone number") + } + + if (personalInfo.address.length < AppConstants.PersonalInfo.MIN_ADDRESS_LENGTH) { + throw ValidationException("Address is required") + } + + return repository.createCraftsmanProfile(personalInfo, categories) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/DeleteCraftsmanAccountUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/DeleteCraftsmanAccountUseCase.kt new file mode 100644 index 0000000..5597d53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/DeleteCraftsmanAccountUseCase.kt @@ -0,0 +1,24 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.repository.CraftsmanRepository + + +class DeleteCraftsmanAccountUseCase( + private val repository: CraftsmanRepository +) { + suspend operator fun invoke( + craftsmanId: String, + confirmDelete: Boolean = false + ) { + if (craftsmanId.isBlank()) { + throw ValidationException("Craftsman ID is required") + } + + if (!confirmDelete) { + throw ValidationException("Please confirm account deletion") + } + + repository.deleteCraftsmanAccount(craftsmanId) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/GetCraftsmanProfileUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/GetCraftsmanProfileUseCase.kt new file mode 100644 index 0000000..f409389 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/GetCraftsmanProfileUseCase.kt @@ -0,0 +1,13 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.entity.Craftsman +import org.example.project.domain.repository.CraftsmanRepository + + +class GetCraftsmanProfileUseCase( + private val repository: CraftsmanRepository +) { + suspend operator fun invoke(): Craftsman { + return repository.getCraftsmanProfile() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/GetCraftsmanStatusUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/GetCraftsmanStatusUseCase.kt new file mode 100644 index 0000000..730e6d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/GetCraftsmanStatusUseCase.kt @@ -0,0 +1,18 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.entity.CraftsmanStatus +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.repository.CraftsmanRepository + + +class GetCraftsmanStatusUseCase( + private val repository: CraftsmanRepository +) { + suspend operator fun invoke(craftsmanId: String): CraftsmanStatus { + if (craftsmanId.isBlank()) { + throw ValidationException("Craftsman ID is required") + } + + return repository.getCraftsmanStatus(craftsmanId) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadIdCardsUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadIdCardsUseCase.kt new file mode 100644 index 0000000..be74ab3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadIdCardsUseCase.kt @@ -0,0 +1,69 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.entity.VerificationDocuments +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.repository.CraftsmanRepository +import org.example.project.domain.service.ValidationService +import org.example.project.domain.util.AppConstants + + +class UploadIdCardsUseCase( + private val repository: CraftsmanRepository, + private val validationService: ValidationService +) { + suspend operator fun invoke( + craftsmanId: String, + idCardFront: ByteArray, + idCardFrontFileName: String, + idCardBack: ByteArray, + idCardBackFileName: String + ): VerificationDocuments { + if (craftsmanId.isBlank()) { + throw ValidationException("Craftsman ID is required") + } + + if (idCardFront.isEmpty()) { + throw ValidationException("Please select front ID card image") + } + + if (idCardBack.isEmpty()) { + throw ValidationException("Please select back ID card image") + } + + if (!validationService.isValidFileSize(idCardFront.size)) { + throw ValidationException("Front ID card image size must be less than ${AppConstants.FileUpload.MAX_FILE_SIZE_MB} MB") + } + + if (!validationService.isValidFileSize(idCardBack.size)) { + throw ValidationException("Back ID card image size must be less than 4MB") + } + + if (!idCardFrontFileName.contains(".")) { + throw ValidationException("Invalid front ID card file name") + } + + if (!idCardBackFileName.contains(".")) { + throw ValidationException("Invalid back ID card file name") + } + + if (!validationService.isValidImageFileName(idCardFrontFileName)) { + throw ValidationException( + "Front ID card must be one of: ${AppConstants.FileUpload.ALLOWED_IMAGE_TYPES.joinToString(", ")}" + ) + } + + if (!validationService.isValidImageFileName(idCardBackFileName)) { + throw ValidationException( + "Back ID card must be one of: ${AppConstants.FileUpload.ALLOWED_IMAGE_TYPES.joinToString(", ")}" + ) + } + + return repository.uploadIdCards( + craftsmanId = craftsmanId, + idCardFront = idCardFront, + idCardFrontFileName = idCardFrontFileName, + idCardBack = idCardBack, + idCardBackFileName = idCardBackFileName + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadProfilePictureUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadProfilePictureUseCase.kt new file mode 100644 index 0000000..6a1ba8c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadProfilePictureUseCase.kt @@ -0,0 +1,41 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.repository.CraftsmanRepository +import org.example.project.domain.service.ValidationService +import org.example.project.domain.util.AppConstants + +class UploadProfilePictureUseCase( + private val repository: CraftsmanRepository, + private val validationService: ValidationService +) { + suspend operator fun invoke( + craftsmanId: String, + profilePicture: ByteArray, + profilePictureFileName: String + ): String { + if (craftsmanId.isBlank()) { + throw ValidationException("Craftsman ID is required") + } + + if (profilePicture.isEmpty()) { + throw ValidationException("Profile picture is required") + } + + if (!validationService.isValidFileSize(profilePicture.size)) { + throw ValidationException( + "Profile picture size must be less than ${AppConstants.FileUpload.MAX_FILE_SIZE_MB}MB" + ) + } + + if (!validationService.isValidImageFileName(profilePictureFileName)) { + throw ValidationException("Profile picture must be one of: ${AppConstants.FileUpload.ALLOWED_IMAGE_TYPES.joinToString(", ")}") + } + + return repository.uploadProfilePicture( + craftsmanId, + profilePicture, + profilePictureFileName + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadWorkPortfolioUseCase.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadWorkPortfolioUseCase.kt new file mode 100644 index 0000000..9f20a22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/usecase/craftsman/UploadWorkPortfolioUseCase.kt @@ -0,0 +1,49 @@ +package org.example.project.domain.usecase.craftsman + +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.model.WorkImage +import org.example.project.domain.repository.CraftsmanRepository +import org.example.project.domain.util.AppConstants + + +class UploadWorkPortfolioUseCase( + private val repository: CraftsmanRepository +) { + suspend operator fun invoke( + craftsmanId: String, + workImages: List + ): List { + if (craftsmanId.isBlank()) { + throw ValidationException("Craftsman ID is required") + } + + if (workImages.isEmpty()) { + throw ValidationException("Please select at least one work image") + } + + if (workImages.size > AppConstants.FileUpload.MAX_PORTFOLIO_IMAGES) { + throw ValidationException( + "You can upload maximum ${AppConstants.FileUpload.MAX_PORTFOLIO_IMAGES} images" + ) + } + + workImages.forEachIndexed { index, image -> + if (image.data.isEmpty()) { + throw ValidationException("Image ${index + 1} is empty") + } + + if (image.data.size > AppConstants.FileUpload.MAX_FILE_SIZE) { + throw ValidationException("Image ${index + 1} size must be less than 4MB") + } + + val extension = image.fileName.substringAfterLast('.', "").lowercase() + if (extension !in AppConstants.FileUpload.ALLOWED_IMAGE_TYPES) { + throw ValidationException( + "Image ${index + 1} must be JPEG or PNG" + ) + } + } + + return repository.uploadWorkPortfolio(craftsmanId, workImages) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/util/AppConstant.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/util/AppConstant.kt new file mode 100644 index 0000000..e4cc451 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/util/AppConstant.kt @@ -0,0 +1,39 @@ +package org.example.project.domain.util + +object AppConstants { + object FileUpload { + const val MAX_FILE_SIZE_MB = 4 + const val MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 // 4MB + + val ALLOWED_IMAGE_TYPES = listOf("jpg", "jpeg", "png") + const val MAX_PORTFOLIO_IMAGES = 4 + + const val MIME_TYPE_JPEG = "image/jpeg" + const val MIME_TYPE_PNG = "image/png" + } + + object PersonalInfo { + const val MIN_FIRST_NAME_LENGTH = 3 + const val MAX_FIRST_NAME_LENGTH = 50 + const val MIN_LAST_NAME_LENGTH = 3 + const val MAX_LAST_NAME_LENGTH = 50 + const val MIN_PHONE_LENGTH = 10 + const val MAX_PHONE_LENGTH = 15 + const val MIN_ADDRESS_LENGTH = 5 + const val MAX_ADDRESS_LENGTH = 50 + const val PHONE_REGEX = "^\\+?[1-9]\\d{1,14}$" + const val EMAIL_REGEX = + "[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}" + + "\\@" + + "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + + "(" + + "\\." + + "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" + + ")+" + } + + object Categories { + const val MIN_CATEGORIES = 1 + const val MAX_CATEGORIES = 7 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/PrimaryButton.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/PrimaryButton.kt index 194dd81..3e8f47d 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/PrimaryButton.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/PrimaryButton.kt @@ -20,7 +20,7 @@ fun PrimaryButton( modifier = modifier, text = text, buttonState =buttonState, - enabled =enabled, + enabled =enabled && buttonState != ButtonState.LOADING, colors = ButtonDefaults.buttonColors( containerColor = AppTheme.craftoColors.button.primary, contentColor = AppTheme.craftoColors.button.onPrimary, diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/ProgressIndicator.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/ProgressIndicator.kt index c548bdf..05e6b0d 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/ProgressIndicator.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/ProgressIndicator.kt @@ -1,5 +1,7 @@ package org.example.project.presentation.designsystem.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -7,6 +9,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -18,12 +21,19 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun ProgressIndicator( currentPage: Int, - totalPage:Int, + totalPage: Int, + animationDuration: Int = 300, modifier: Modifier = Modifier, progressColor: Color = AppTheme.craftoColors.brand.primary, trackColor: Color = AppTheme.craftoColors.background.card, ) { + val animatedProgress by animateFloatAsState( + targetValue = (currentPage.toFloat() / totalPage.toFloat()).coerceIn(0f, 1f), + animationSpec = tween(durationMillis = animationDuration), + label = "progress_animation" + ) + Box( modifier = modifier .fillMaxWidth() @@ -36,7 +46,7 @@ fun ProgressIndicator( ) { Box( modifier = Modifier - .fillMaxWidth((currentPage.toFloat() / totalPage.toFloat()).coerceIn(0f,1f)) + .fillMaxWidth(animatedProgress) .height(8.dp) .clip(RoundedCornerShape(AppTheme.craftoRadius.full)) .background( @@ -44,9 +54,7 @@ fun ProgressIndicator( RoundedCornerShape(AppTheme.craftoRadius.full) ) ) - } - } @Preview diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/SelectionCard.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/SelectionCard.kt index a1c88ac..810555c 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/SelectionCard.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/SelectionCard.kt @@ -3,6 +3,8 @@ package org.example.project.presentation.designsystem.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card @@ -14,9 +16,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import crafto.composeapp.generated.resources.Res import crafto.composeapp.generated.resources.selection_card_img +import crafto.composeapp.generated.resources.selection_craftsman import org.example.project.presentation.designsystem.textstyle.AppTheme import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -42,7 +46,10 @@ fun SelectionCard( Card( onClick = onCardClick, - modifier.border(1.dp, strokeColor, RoundedCornerShape(AppTheme.craftoRadius.xl)), + modifier=modifier + .fillMaxSize() + .border(1.dp, strokeColor, RoundedCornerShape(AppTheme.craftoRadius.xl)) + , shape = RoundedCornerShape(AppTheme.craftoRadius.xl), colors = CardDefaults.cardColors( background @@ -50,8 +57,13 @@ fun SelectionCard( ) { Image( painter = img, + alignment = Alignment.Center, contentDescription = null, - modifier = Modifier.padding(12.dp) + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(12.dp), ) Text( @@ -75,7 +87,7 @@ fun SelectionCard( @Composable private fun SelectionCardPreview() { SelectionCard( - img = painterResource(Res.drawable.selection_card_img), + img = painterResource(Res.drawable.selection_craftsman), title = "Title", caption = "Caption", isSelected = true, diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/chip.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/chip.kt index 541169a..9957dc3 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/chip.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/designsystem/components/chip.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.example.project.presentation.designsystem.colors.Background import org.example.project.presentation.designsystem.textstyle.AppTheme import org.jetbrains.compose.ui.tooling.preview.Preview @@ -26,7 +27,10 @@ fun Chip( modifier: Modifier = Modifier, text: String, isSelected: Boolean, + isBordered: Boolean = false, textColor: Color, + selectedBackgroundColor: Color = AppTheme.craftoColors.brand.tertiary, + unselectedBackgroundColor: Color = AppTheme.craftoColors.shade.quinary, borderColor: Color = AppTheme.craftoColors.brand.secondary, onChipSelected: (String) -> Unit = {}, ) { @@ -35,13 +39,13 @@ fun Chip( .clip(RoundedCornerShape(AppTheme.craftoRadius.full)) .background( if (isSelected) - AppTheme.craftoColors.brand.tertiary + selectedBackgroundColor else - AppTheme.craftoColors.shade.quinary, + unselectedBackgroundColor, shape = RoundedCornerShape(AppTheme.craftoRadius.full) ) .then( - if (isSelected) modifier.border( + if (isSelected && isBordered) modifier.border( 1.dp, borderColor, RoundedCornerShape( AppTheme.craftoRadius.full diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/mapper/CraftsmanMapper.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/mapper/CraftsmanMapper.kt new file mode 100644 index 0000000..4de8938 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/mapper/CraftsmanMapper.kt @@ -0,0 +1,30 @@ +package org.example.project.presentation.mapper + +import androidx.compose.ui.graphics.Color +import org.example.project.domain.entity.Category +import org.example.project.domain.entity.PersonalInfo +import org.example.project.domain.model.WorkImage +import org.example.project.presentation.model.CategoryUi +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.model.PersonalInfoUiModel + +fun PersonalInfoUiModel.toDomain(): PersonalInfo { + return PersonalInfo( + firstName = firstName, + lastName = lastName, + phoneNumber = phoneNumber, + address = address + ) +} + +fun List.toWorkImages(): List { + return map { imageData -> + WorkImage( + data = imageData.byteArray, + fileName = imageData.fileName + ) + } +} + +fun Category.toUi(): CategoryUi = + CategoryUi(id, title, Color(colorHex)) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/mapper/Exception.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/mapper/Exception.kt new file mode 100644 index 0000000..af9a6d0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/mapper/Exception.kt @@ -0,0 +1,70 @@ +package org.example.project.presentation.mapper + +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException +import org.example.project.domain.exception.AlreadyExistsException +import org.example.project.domain.exception.ApiException +import org.example.project.domain.exception.ForbiddenException +import org.example.project.domain.exception.NetworkException +import org.example.project.domain.exception.NotFoundException +import org.example.project.domain.exception.ServerUnavailableException +import org.example.project.domain.exception.UnauthorizedException +import org.example.project.domain.exception.ValidationException +import org.example.project.presentation.shared.base.ErrorUiState +import org.example.project.util.AppLogger + +fun Throwable.toErrorUiState(): ErrorUiState { + return when (this) { + // ========== CLIENT-SIDE: NETWORK ISSUES ========== + is NetworkException, + is SocketTimeoutException, + is HttpRequestTimeoutException -> ErrorUiState( + message = "Please check your internet connection and try again.", + errorType = ErrorUiState.ErrorType.NETWORK + ) + is ValidationException -> ErrorUiState( + message = message ?: "Please check your input.", + errorType = ErrorUiState.ErrorType.VALIDATION + ) + + // ========== SERVER-SIDE: SERVER UNAVAILABLE ========== + is ServerUnavailableException, + is ConnectTimeoutException -> ErrorUiState( + message = message ?: "Server is currently unavailable. Please try again later.", + errorType = ErrorUiState.ErrorType.SERVER + ) + + // ========== SERVER-SIDE: API ERRORS ========== + is UnauthorizedException -> ErrorUiState( + message = message ?: "Please login to continue.", + errorType = ErrorUiState.ErrorType.AUTHENTICATION + ) + is ForbiddenException -> ErrorUiState( + message = message ?: "You don't have permission to perform this action.", + errorType = ErrorUiState.ErrorType.AUTHENTICATION + ) + is NotFoundException -> ErrorUiState( + message = message ?: "The requested resource was not found.", + errorType = ErrorUiState.ErrorType.SERVER + ) + is AlreadyExistsException -> ErrorUiState( + message = message ?: "This resource already exists.", + errorType = ErrorUiState.ErrorType.SERVER + ) + is ApiException -> ErrorUiState( + message = message ?: "Server error occurred. Please try again.", + errorType = ErrorUiState.ErrorType.SERVER + ) + + // ========== UNKNOWN ERRORS ========== + else -> { + AppLogger.e("ThrowableEX", this.message ?: "Unknown error") + println("⚠️ Unexpected error: ${this::class.simpleName} - ${this.message}") + ErrorUiState( + message = message ?: "Something went wrong. Please try again later.", + errorType = ErrorUiState.ErrorType.UNKNOWN + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/CategoryUi.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/CategoryUi.kt new file mode 100644 index 0000000..c2d4e04 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/CategoryUi.kt @@ -0,0 +1,10 @@ +package org.example.project.presentation.model + +import androidx.compose.ui.graphics.Color + +data class CategoryUi( + val id: Int, + val title: String, + val color: Color, + val isSelected: Boolean = false +) diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/ImageData.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/ImageData.kt new file mode 100644 index 0000000..150ccb9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/ImageData.kt @@ -0,0 +1,20 @@ +package org.example.project.presentation.model + +data class ImageData( + val uri: String, + val fileName: String, + val byteArray: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as ImageData + return uri == other.uri && fileName == other.fileName + } + + override fun hashCode(): Int { + var result = uri.hashCode() + result = 31 * result + fileName.hashCode() + return result + } +} diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/PersonalInfoUiModel.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/PersonalInfoUiModel.kt new file mode 100644 index 0000000..bbfd55d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/model/PersonalInfoUiModel.kt @@ -0,0 +1,9 @@ +package org.example.project.presentation.model + +data class PersonalInfoUiModel( + val firstName: String, + val lastName: String, + val phoneNumber: String, + val address: String + +) diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/AccountSetupTopBar.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/AccountSetupTopBar.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/AccountSetupTopBar.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/AccountSetupTopBar.kt index 8470ec9..d05684d 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/AccountSetupTopBar.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/AccountSetupTopBar.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.ui.screens.setupScreens.component +package org.example.project.presentation.screens.setup.composable import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -6,6 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -27,6 +28,9 @@ import org.jetbrains.compose.ui.tooling.preview.Preview fun AccountSetupTopBar( modifier: Modifier = Modifier, currentPage: Int, + totalPages: Int=4, + animatedDuration: Int = 300, + showBackButton: Boolean = true, onBackButtonClick: () -> Unit ) { @@ -36,10 +40,15 @@ fun AccountSetupTopBar( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - BackButton(onBackButtonClick = onBackButtonClick) + if (showBackButton) { + BackButton(onBackButtonClick = onBackButtonClick) + } else { + Spacer(modifier = Modifier.size(48.dp)) + } ProgressIndicator( currentPage = currentPage, - totalPage = 4, + totalPage = totalPages, + animationDuration = animatedDuration, modifier = Modifier.fillMaxWidth(0.75f), ) } @@ -90,7 +99,7 @@ fun AccountSetupTopBarDarkPreview() { AppTheme(isDarkTheme = true) { AccountSetupTopBar( onBackButtonClick = {}, - currentPage = 2 + currentPage = 3 ) } diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/PicturePicker.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/PicturePicker.kt new file mode 100644 index 0000000..ba447da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/PicturePicker.kt @@ -0,0 +1,132 @@ +package org.example.project.presentation.screens.setup.composable + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.camera +import crafto.composeapp.generated.resources.x +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.util.rememberImagePicker +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ImagePicker( + modifier: Modifier = Modifier, + selectedImage: ImageData? = null, + onImageSelected: (ImageData) -> Unit, + onRemove: () -> Unit, + shape: Shape? = null, + imageSize: Dp? = null, + contentDescriptor: String? = null, + onError: (String) -> Unit, + enabled: Boolean = true, + isUploading: Boolean = false +) { + val imagePicker = rememberImagePicker( + singleSelection = true, + onImagesSelected = { images -> + images.firstOrNull()?.let { imageData -> + onImageSelected(imageData) + } + }, + onError = { errorMessage -> + onError(errorMessage) + } + ) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box(contentAlignment=Alignment.TopEnd) { + Box( + modifier = Modifier + .then(if (imageSize != null) Modifier.size(imageSize) else Modifier.fillMaxSize()) + .then(shape?.let { Modifier.clip(it) } ?: Modifier) + .background(AppTheme.craftoColors.background.card) + .border( + BorderStroke( + width = 1.dp, + color = AppTheme.craftoColors.shade.quinary + ), + shape = shape ?: RectangleShape + ) + .clickable(enabled = enabled && !isUploading) { + imagePicker.launch() + }, + contentAlignment = Alignment.Center + ) { + if (selectedImage != null) { + AsyncImage( + model = selectedImage.byteArray, + contentDescription = contentDescriptor, + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Column( + modifier.matchParentSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + ) { + Icon( + painter = painterResource(Res.drawable.camera), + contentDescription = "Add Profile Picture", + tint = AppTheme.craftoColors.shade.secondary, + modifier = Modifier.size(32.dp) + ) + + Text( + text = "Tap here", + style = AppTheme.textStyle.body.smallMedium, + color = AppTheme.craftoColors.shade.secondary + ) + } + } + } + if (selectedImage != null) { + IconButton( + onClick = onRemove, + modifier = Modifier + .size(24.dp) + .background( + AppTheme.craftoColors.background.card.copy(alpha = 0.8f), + RoundedCornerShape(8.dp) + ) + .padding(4.dp) + ) { + Icon( + painter = painterResource(Res.drawable.x), + contentDescription = "Remove Image", + tint = AppTheme.craftoColors.shade.primary + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/ProfilePictureSelector.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/ProfilePictureSelector.kt new file mode 100644 index 0000000..50f2ff8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/ProfilePictureSelector.kt @@ -0,0 +1,119 @@ +package org.example.project.presentation.screens.setup.composable + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.camera +import crafto.composeapp.generated.resources.plus +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.util.rememberImagePicker +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ProfilePictureSelector( + modifier: Modifier = Modifier, + selectedImage: ImageData? = null, + onImageSelected: (ImageData) -> Unit, + onError: (String) -> Unit, + enabled: Boolean = true, + isUploading: Boolean = false +) { + val imagePicker = rememberImagePicker( + singleSelection = true, + onImagesSelected = { images -> + images.firstOrNull()?.let { imageData -> + onImageSelected(imageData) + } + }, + onError = { errorMessage -> + onError(errorMessage) + } + ) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(AppTheme.craftoColors.background.card) + .border( + BorderStroke( + width = 1.dp, + color = AppTheme.craftoColors.shade.quinary + + ), + CircleShape + ) + .clickable(enabled = enabled && !isUploading) { + imagePicker.launch() + }, + contentAlignment = Alignment.Center + ) { + if (selectedImage != null) { + AsyncImage( + model = selectedImage.byteArray, + contentDescription = "Profile Picture", + modifier = Modifier + .size(96.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(AppTheme.craftoColors.background.card) + .align(Alignment.BottomEnd), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(Res.drawable.plus), + contentDescription = "Change Photo", + tint = AppTheme.craftoColors.button.onPrimary, + modifier = Modifier.size(16.dp) + ) + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = painterResource(Res.drawable.camera), + contentDescription = "Add Profile Picture", + tint = AppTheme.craftoColors.shade.secondary, + modifier = Modifier.size(32.dp) + ) + + Text( + text = "Tap here", + style = AppTheme.textStyle.body.smallMedium, + color = AppTheme.craftoColors.shade.secondary + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/SetupScreenScaffold.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/SetupScreenScaffold.kt similarity index 65% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/SetupScreenScaffold.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/SetupScreenScaffold.kt index c5a921f..04994fa 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/SetupScreenScaffold.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/SetupScreenScaffold.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.ui.screens.setupScreens.component +package org.example.project.presentation.screens.setup.composable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -17,23 +17,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import crafto.composeapp.generated.resources.Res -import crafto.composeapp.generated.resources.account_setup_craftsman_category_description -import crafto.composeapp.generated.resources.account_setup_craftsman_category_title import crafto.composeapp.generated.resources.next -import org.example.project.data.memory.dataSource.categoryList import org.example.project.presentation.designsystem.components.ButtonState import org.example.project.presentation.designsystem.components.PrimaryButton import org.example.project.presentation.designsystem.textstyle.AppTheme import org.example.project.presentation.util.DeviceConfiguration -import org.example.project.presentation.screens.setupScreens.AccountSetupCategoryState -import org.example.project.presentation.screens.setupScreens.AccountSetupState import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun SetupScreenScaffold( modifier: Modifier = Modifier, currentPageNumber: Int, + totalPages: Int = 4, + nextButtonText: String = stringResource(Res.string.next), + nextButtonEnabled: Boolean = true, + nextButtonState: ButtonState = ButtonState.Enable, title: String, description: String, onBackButtonClick: () -> Unit, @@ -51,10 +49,14 @@ fun SetupScreenScaffold( modifier = modifier, currentPageNumber = currentPageNumber, title = title, + totalPages = totalPages, + nextButtonText = nextButtonText, + nextButtonEnabled = nextButtonEnabled, + nextButtonState = nextButtonState, description = description, onBackButtonClick = onBackButtonClick, onNextButtonClick = onNextButtonClick, - content = content + content = content, ) } @@ -63,6 +65,10 @@ fun SetupScreenScaffold( LandscapeLayout( modifier = modifier, currentPageNumber = currentPageNumber, + totalPages = totalPages, + nextButtonText = nextButtonText, + nextButtonEnabled = nextButtonEnabled, + nextButtonState = nextButtonState, title = title, description = description, onBackButtonClick = onBackButtonClick, @@ -80,6 +86,10 @@ private fun PortraitLayout( modifier: Modifier = Modifier, currentPageNumber: Int, title: String, + totalPages: Int, + nextButtonText: String, + nextButtonEnabled: Boolean, + nextButtonState: ButtonState, description: String, onBackButtonClick: () -> Unit, onNextButtonClick: () -> Unit, @@ -94,21 +104,32 @@ private fun PortraitLayout( verticalArrangement = Arrangement.spacedBy(32.dp), ) { - AccountSetupTopBar(onBackButtonClick = onBackButtonClick, currentPage = currentPageNumber) + AccountSetupTopBar( + onBackButtonClick = onBackButtonClick, + currentPage = currentPageNumber, + totalPages = totalPages ) + TitleDescriptionBox( modifier = Modifier.weight(1f), title = title, description = description, ) - content() + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + content() + } + PrimaryButton( - text = stringResource(Res.string.next), - enabled = true, - buttonState = ButtonState.Enable, + text = nextButtonText, + enabled = nextButtonEnabled, + buttonState = nextButtonState, modifier = Modifier.fillMaxWidth(), onClick = onNextButtonClick ) - } } @@ -116,6 +137,10 @@ private fun PortraitLayout( private fun LandscapeLayout( modifier: Modifier = Modifier, currentPageNumber: Int, + totalPages: Int, + nextButtonText: String, + nextButtonEnabled: Boolean, + nextButtonState: ButtonState, title: String, description: String, onBackButtonClick: () -> Unit, @@ -131,7 +156,10 @@ private fun LandscapeLayout( verticalArrangement = Arrangement.spacedBy(32.dp), ) { - AccountSetupTopBar(onBackButtonClick = onBackButtonClick, currentPage = currentPageNumber) + AccountSetupTopBar( + onBackButtonClick = onBackButtonClick, + currentPage = currentPageNumber, + totalPages = totalPages) Row( modifier = Modifier .fillMaxWidth() @@ -149,9 +177,9 @@ private fun LandscapeLayout( } PrimaryButton( - text = stringResource(Res.string.next), - enabled = true, - buttonState = ButtonState.Enable, + text = nextButtonText, + enabled = nextButtonEnabled, + buttonState = nextButtonState, modifier = Modifier .fillMaxWidth(0.5f) .align(Alignment.End), @@ -161,35 +189,35 @@ private fun LandscapeLayout( } } -@Preview -@Composable -fun SetupScreenScaffoldLightPreview() { - SetupScreenScaffold( - currentPageNumber = 2, - title = stringResource(Res.string.account_setup_craftsman_category_title), - description = stringResource(Res.string.account_setup_craftsman_category_description), - onBackButtonClick = {}, - onNextButtonClick = {}, - - ) - { - CategoryActionBox( - state = AccountSetupState( - categoryState = AccountSetupCategoryState( - categories = categoryList - ) - ), - onChipSelected = {} - ) - } - - -} - -@Preview -@Composable -fun SetupScreenScaffoldDarkPreview() { - AppTheme(isDarkTheme = true) { - SetupScreenScaffoldLightPreview() - } -} \ No newline at end of file +//@Preview +//@Composable +//fun SetupScreenScaffoldLightPreview() { +// SetupScreenScaffold( +// currentPageNumber = 2, +// title = stringResource(Res.string.account_setup_craftsman_category_title), +// description = stringResource(Res.string.account_setup_craftsman_category_description), +// onBackButtonClick = {}, +// onNextButtonClick = {}, +// +// ) +// { +// CategoryActionBox( +// state = AccountSetupState( +// categoryState = AccountSetupCategoryState( +// categories = categorySeed +// ) +// ), +// onChipSelected = {} +// ) +// } +// +// +//} + +//@Preview +//@Composable +//fun SetupScreenScaffoldDarkPreview() { +// AppTheme(isDarkTheme = true) { +// SetupScreenScaffoldLightPreview() +// } +//} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/TitleDescriptionBox.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/TitleDescriptionBox.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/TitleDescriptionBox.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/TitleDescriptionBox.kt index 1e179c8..ae8034b 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/TitleDescriptionBox.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/TitleDescriptionBox.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.ui.screens.setupScreens.component +package org.example.project.presentation.screens.setup.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,7 +27,6 @@ fun TitleDescriptionBox( title = title, description = description ) - } } diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/IdentityVerificationPage.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/IdentityVerificationPage.kt new file mode 100644 index 0000000..4c0e16e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/IdentityVerificationPage.kt @@ -0,0 +1,96 @@ +package org.example.project.presentation.screens.setup.composable.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.id_card_image +import crafto.composeapp.generated.resources.personal_info +import crafto.composeapp.generated.resources.skip_for_now +import crafto.composeapp.generated.resources.upload_back_of_national_id +import crafto.composeapp.generated.resources.upload_front_of_national_id +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.screens.setup.composable.ImagePicker +import org.jetbrains.compose.resources.stringResource + +@Composable +fun IdentityVerificationPage( + idCardFront: ImageData?, + idCardBack: ImageData?, + onIdCardSelected: (isFront: Boolean, imageData: ImageData) -> Unit, + onFrontImageRemoved: () -> Unit, + onBackImageRemoved: () -> Unit, + onSkip: () -> Unit, + onErrorMessage: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UploadBox( + modifier = Modifier.weight(1f), + title = stringResource(Res.string.upload_front_of_national_id), + image = idCardFront, + onSelect = { img -> onIdCardSelected(true, img) }, + onError = onErrorMessage, + onRemove = onFrontImageRemoved + ) + + UploadBox( + modifier = Modifier.weight(1f), + title = stringResource(Res.string.upload_back_of_national_id), + image = idCardBack, + onSelect = { img -> onIdCardSelected(false, img) }, + onError = onErrorMessage, + onRemove = onBackImageRemoved + ) + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onSkip + ) { Text(stringResource(Res.string.skip_for_now)) } + } +} + +@Composable +private fun UploadBox( + modifier: Modifier=Modifier, + title: String, + image: ImageData?, + onSelect: (ImageData) -> Unit, + onError: (String) -> Unit, + onRemove: () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, style = AppTheme.textStyle.body.smallMedium) + + ImagePicker( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + onImageSelected = onSelect, + onError = onError, + selectedImage = image, + onRemove = onRemove, + contentDescriptor = stringResource(Res.string.id_card_image), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/PersonalInfoPage.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/PersonalInfoPage.kt new file mode 100644 index 0000000..61a0b2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/PersonalInfoPage.kt @@ -0,0 +1,88 @@ +package org.example.project.presentation.screens.setup.composable.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.address +import crafto.composeapp.generated.resources.first_name +import crafto.composeapp.generated.resources.last_name +import crafto.composeapp.generated.resources.phone_number +import org.example.project.presentation.designsystem.components.TextField +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.model.PersonalInfoUiModel +import org.example.project.presentation.screens.setup.composable.ImagePicker +import org.jetbrains.compose.resources.stringResource + +@Composable +fun PersonalInfoPage( + personalInfo: PersonalInfoUiModel, + onPersonalInfoChanged: (PersonalInfoUiModel) -> Unit, + profilePicture: ImageData? = null, + onProfilePictureSelected: (ImageData) -> Unit, + onRemove: () -> Unit, + onImagePickerError: (String) -> Unit, + isLoading: Boolean, + isUploadingProfilePicture: Boolean = false +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + ImagePicker( + modifier = Modifier.fillMaxWidth(), + selectedImage = profilePicture, + onImageSelected = onProfilePictureSelected, + onError = onImagePickerError, + imageSize = 100.dp, + shape = CircleShape, + enabled = !isLoading, + isUploading = isUploadingProfilePicture, + onRemove = onRemove, + ) + + TextField( + labelText = stringResource(Res.string.first_name), + text = personalInfo.firstName, + onTextChange = { onPersonalInfoChanged(personalInfo.copy(firstName = it)) }, + enabledState = !isLoading, + inputKeyboard = KeyboardOptions(imeAction = ImeAction.Next) + ) + TextField( + labelText = stringResource(resource = Res.string.last_name), + text = personalInfo.lastName, + onTextChange = { onPersonalInfoChanged(personalInfo.copy(lastName = it)) }, + enabledState = !isLoading, + inputKeyboard = KeyboardOptions(imeAction = ImeAction.Next) + ) + TextField( + text = personalInfo.phoneNumber, + onTextChange = { onPersonalInfoChanged(personalInfo.copy(phoneNumber = it)) }, + labelText = stringResource(Res.string.phone_number), + enabledState = !isLoading, + inputKeyboard = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Phone) + ) + TextField( + text = personalInfo.address, + onTextChange = { onPersonalInfoChanged(personalInfo.copy(address = it)) }, + labelText = stringResource(Res.string.address), + maxLines = 3, + enabledState = !isLoading, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/PortfolioUploadPage.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/PortfolioUploadPage.kt new file mode 100644 index 0000000..b6f7943 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/PortfolioUploadPage.kt @@ -0,0 +1,188 @@ +package org.example.project.presentation.screens.setup.composable.page + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.add_photos +import crafto.composeapp.generated.resources.camera +import crafto.composeapp.generated.resources.describe_your_work +import crafto.composeapp.generated.resources.describe_your_work_hint +import crafto.composeapp.generated.resources.plus +import crafto.composeapp.generated.resources.x +import org.example.project.presentation.designsystem.components.TextField +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.util.rememberImagePicker +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +@Composable +fun PortfolioUploadPage( + images: List, + canAddMore: Boolean, + onAddPhotosClicked: (List) -> Unit, + onImageRemoved: (Int) -> Unit, + workDescription: String, + onDescriptionChanged: (String) -> Unit, + onError: (String) -> Unit +) { + val imagePicker = rememberImagePicker( + singleSelection = false, + onImagesSelected = { newImages -> + onAddPhotosClicked(newImages) + }, + onError = onError + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Either show the empty placeholder or thumbnails + add button + if (images.isEmpty()) { + EmptyPortfolioBox( + onAddPhotosClicked={ imagePicker.launch() } + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + images.forEachIndexed { index, image -> + PortfolioThumbnail(image) { + onImageRemoved(index) + } + } + if (canAddMore) { + AddPhotoBox( + onAddPhotosClicked= { imagePicker.launch() } + ) + } + } + } + + TextField( + labelText = stringResource(Res.string.describe_your_work), + text = workDescription, + onTextChange = onDescriptionChanged, + hint = stringResource(Res.string.describe_your_work_hint), + modifier = Modifier.fillMaxWidth(), + maxLines = 4 + ) + } +} + +@Composable +private fun EmptyPortfolioBox(onAddPhotosClicked: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f) + .background( + AppTheme.craftoColors.background.card, + RoundedCornerShape(12.dp) + ) + .clickable { onAddPhotosClicked() }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(Res.drawable.camera), + contentDescription = "Add Photos", + tint = AppTheme.craftoColors.shade.secondary, + modifier = Modifier.size(36.dp) + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.add_photos), + style = AppTheme.textStyle.body.smallMedium, + color = AppTheme.craftoColors.shade.secondary + ) + } + } +} + +// Thumbnail with remove button +@Composable +private fun PortfolioThumbnail( + imageData: ImageData, + onRemove: () -> Unit +) { + Box( + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(12.dp)), + contentAlignment = Alignment.TopEnd + ) { + AsyncImage( + model = imageData.byteArray, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + IconButton( + onClick = onRemove, + modifier = Modifier + .size(24.dp) + .background( + AppTheme.craftoColors.background.card.copy(alpha = 0.6f), + RoundedCornerShape(8.dp) + ) + .padding(4.dp) + ) { + Icon( + painter = painterResource(Res.drawable.x), + contentDescription = "Remove", + tint = AppTheme.craftoColors.shade.primary + ) + } + } +} + +// Add (+) box shown beside thumbnails +@Composable +private fun AddPhotoBox(onAddPhotosClicked: () -> Unit) { + Box( + modifier = Modifier + .size(100.dp) + .background( + AppTheme.craftoColors.background.card, + RoundedCornerShape(12.dp) + ) + .clickable { onAddPhotosClicked() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(Res.drawable.plus), + contentDescription = "Add", + tint = AppTheme.craftoColors.shade.secondary, + modifier = Modifier.size(21.dp) + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/ServiceSelectionPage.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/ServiceSelectionPage.kt new file mode 100644 index 0000000..c2e4e89 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/ServiceSelectionPage.kt @@ -0,0 +1,39 @@ +package org.example.project.presentation.screens.setup.composable.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.example.project.presentation.designsystem.components.Chip +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.model.CategoryUi + +@Composable +fun ServiceSelectionPage( + availableCategories: List, + selectedServiceIds: Set, + onCategoryToggled: (Int) -> Unit +) { + FlowRow( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + availableCategories.forEach { category -> + Chip( + isSelected = category.id in selectedServiceIds, + onChipSelected = { onCategoryToggled(category.id) }, + textColor= if (category.id in selectedServiceIds) + AppTheme.craftoColors.background.card + else + AppTheme.craftoColors.shade.secondary, + text = category.title, + selectedBackgroundColor = category.color, + unselectedBackgroundColor = AppTheme.craftoColors.background.card, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/UserTypeSelectionPage.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/UserTypeSelectionPage.kt new file mode 100644 index 0000000..62a8677 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/composable/page/UserTypeSelectionPage.kt @@ -0,0 +1,48 @@ +package org.example.project.presentation.screens.setup.composable.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.craftsman +import crafto.composeapp.generated.resources.craftsman_description +import crafto.composeapp.generated.resources.customer +import crafto.composeapp.generated.resources.customer_description +import crafto.composeapp.generated.resources.selection_craftsman +import crafto.composeapp.generated.resources.selection_customer +import org.example.project.presentation.designsystem.components.SelectionCard +import org.example.project.presentation.screens.setup.craftsmansetup.UserType +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun UserTypeSelectionPage( + selectedType: UserType?, + onTypeSelected: (UserType) -> Unit +) { + Row( + modifier = Modifier + .fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + SelectionCard( + img = painterResource(Res.drawable.selection_customer), + title = stringResource(Res.string.customer), + caption = stringResource(Res.string.customer_description), + isSelected = selectedType == UserType.CUSTOMER, + onCardClick = { onTypeSelected(UserType.CUSTOMER) }, + modifier = Modifier.weight(1f) + ) + SelectionCard( + img = painterResource(Res.drawable.selection_craftsman), + title = stringResource(Res.string.craftsman), + caption = stringResource(Res.string.craftsman_description), + isSelected = selectedType == UserType.CRAFTSMAN, + onCardClick = { onTypeSelected(UserType.CRAFTSMAN) }, + modifier = Modifier.weight(1f) + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupEffect.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupEffect.kt new file mode 100644 index 0000000..1372a6d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupEffect.kt @@ -0,0 +1,5 @@ +package org.example.project.presentation.screens.setup.craftsmansetup + +sealed interface CraftsmanRegistrationEffect { + data object RegistrationComplete : CraftsmanRegistrationEffect +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupInteractionListener.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupInteractionListener.kt new file mode 100644 index 0000000..bc33d78 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupInteractionListener.kt @@ -0,0 +1,28 @@ +package org.example.project.presentation.screens.setup.craftsmansetup + +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.model.PersonalInfoUiModel +import org.example.project.presentation.shared.base.ErrorUiState + +interface CraftsmanSetupInteractionListener { + fun onUserTypeSelected(userType: UserType) + + fun onCategoryToggled(categoryId: Int) + fun onPersonalInfoChanged(personalInfo: PersonalInfoUiModel) + + fun onIdCardSelected(isFront: Boolean, imageData: ImageData) + fun onUploadIdCards() + fun onSkipIdentityVerification() + + fun onPortfolioImagesAdded(images: List) + fun onPortfolioImageRemoved(index: Int) + fun onProfilePictureRemoved() + fun onFrontIdCardRemoved() + fun onBackIdCardRemoved() + fun onWorkDescriptionChanged(description: String) + fun onUploadPortfolio() + + fun onProfilePictureSelected(imageData: ImageData) + fun onImagePickerError(error: ErrorUiState) + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupScreen.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupScreen.kt new file mode 100644 index 0000000..d9d4369 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupScreen.kt @@ -0,0 +1,276 @@ +package org.example.project.presentation.screens.setup.craftsmansetup + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import crafto.composeapp.generated.resources.Res +import crafto.composeapp.generated.resources.identity_verification +import crafto.composeapp.generated.resources.location_hint +import crafto.composeapp.generated.resources.personal_info +import crafto.composeapp.generated.resources.portfolio_upload +import crafto.composeapp.generated.resources.registration_step_1_description +import crafto.composeapp.generated.resources.registration_step_2_description +import crafto.composeapp.generated.resources.registration_step_3_description +import crafto.composeapp.generated.resources.registration_step_4_description +import crafto.composeapp.generated.resources.registration_step_5_description +import crafto.composeapp.generated.resources.service_selection +import crafto.composeapp.generated.resources.user_type +import org.example.project.presentation.designsystem.components.ButtonState +import org.example.project.presentation.designsystem.components.TextButton +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.screens.setup.composable.SetupScreenScaffold +import org.example.project.presentation.screens.setup.composable.page.IdentityVerificationPage +import org.example.project.presentation.screens.setup.composable.page.PersonalInfoPage +import org.example.project.presentation.screens.setup.composable.page.PortfolioUploadPage +import org.example.project.presentation.screens.setup.composable.page.ServiceSelectionPage +import org.example.project.presentation.screens.setup.composable.page.UserTypeSelectionPage +import org.example.project.presentation.shared.base.ErrorUiState +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CraftsmanSetupScreen( + viewModel: CraftsmanSetupViewModel= koinViewModel(), + onComplete: () -> Unit={}, + onClose: () -> Unit={} +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val pagerState = rememberPagerState( + initialPage = state.currentPageIndex, + pageCount = { state.totalPages } + ) + + LaunchedEffect(pagerState.currentPage) { + viewModel.onPageChanged(pagerState.currentPage) + } + + LaunchedEffect(state.currentPageIndex) { + pagerState.animateScrollToPage(state.currentPageIndex) + } + + + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + CraftsmanRegistrationEffect.RegistrationComplete -> onComplete() + } + } + } + + AnimatedVisibility( + visible = true, + enter = EnterTransition.None, + exit = ExitTransition.None + ) { + CraftsmanSetupContent( + state = state, + viewModel = viewModel, + pagerState = pagerState, + onClose = onClose + ) + } + + AnimatedVisibility( + visible = state.error != null, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut() + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + state.error?.let { error -> + ErrorSnackbar( + error = error, + onDismiss = viewModel::clearError + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CraftsmanSetupContent( + state: CraftsmanSetupUiState, + viewModel: CraftsmanSetupViewModel, + pagerState: PagerState, + onClose: () -> Unit +) { + SetupScreenScaffold( + currentPageNumber = state.currentPageIndex + 1, + totalPages = state.totalPages, + nextButtonText = state.nextButtonText, + nextButtonEnabled = state.canNavigateNext && !state.isLoading, + nextButtonState = if (state.isLoading) ButtonState.LOADING else ButtonState.Enable, + onBackButtonClick = { + if (state.currentPageIndex == 0) onClose() + else viewModel.navigateBack() + }, + onNextButtonClick = { + when (state.currentStep) { + RegistrationStep.IDENTITY_VERIFICATION -> { + if (state.idCardFront != null && state.idCardBack != null) { + viewModel.onUploadIdCards() + } else { + viewModel.onSkipIdentityVerification() + } + } + else -> viewModel.navigateNext() + } + }, + title = when (state.currentStep) { + RegistrationStep.USER_TYPE -> { + stringResource(Res.string.user_type) } + RegistrationStep.SERVICE_SELECTION -> { + stringResource(Res.string.service_selection) + } + RegistrationStep.PERSONAL_INFO -> { + stringResource(Res.string.personal_info) + } + RegistrationStep.PORTFOLIO_UPLOAD -> { + stringResource(Res.string.portfolio_upload) + } + RegistrationStep.IDENTITY_VERIFICATION -> { + stringResource(Res.string.identity_verification) + } + }, + description =when (state.currentStep) { + RegistrationStep.USER_TYPE -> { + stringResource(Res.string.registration_step_1_description) + } + RegistrationStep.SERVICE_SELECTION -> { + stringResource(Res.string.registration_step_2_description) + } + RegistrationStep.PERSONAL_INFO -> { + stringResource(Res.string.registration_step_3_description) + } + RegistrationStep.PORTFOLIO_UPLOAD -> { + stringResource(Res.string.registration_step_4_description) + } + RegistrationStep.IDENTITY_VERIFICATION -> { + stringResource(Res.string.registration_step_5_description) + } + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(AppTheme.craftoColors.background.screen) + ) { + HorizontalPager( + state = pagerState, + userScrollEnabled = state.isSwipeEnabled && state.canNavigateNext + ) { page -> + when (RegistrationStep.fromIndex(page)) { + RegistrationStep.USER_TYPE -> { + UserTypeSelectionPage( + selectedType = state.userType, + onTypeSelected = viewModel::onUserTypeSelected, + ) + } + RegistrationStep.SERVICE_SELECTION -> { + ServiceSelectionPage( + availableCategories = state.availableCategories, + onCategoryToggled = viewModel::onCategoryToggled, + selectedServiceIds = state.selectedCategoryIds, + ) + } + + RegistrationStep.PERSONAL_INFO -> { + PersonalInfoPage( + personalInfo = state.personalInfo, + profilePicture = state.profilePicture, + onPersonalInfoChanged = viewModel::onPersonalInfoChanged, + onProfilePictureSelected = viewModel::onProfilePictureSelected, + onImagePickerError = { errorMessage -> + viewModel.onImagePickerError(ErrorUiState(errorMessage)) + }, + isLoading = state.isLoading, + isUploadingProfilePicture = state.isUploadingProfilePicture, + onRemove = viewModel::onProfilePictureRemoved + ) + } + + RegistrationStep.PORTFOLIO_UPLOAD -> { + PortfolioUploadPage( + images = state.portfolioImages, + workDescription = state.workDescription, + canAddMore = state.canAddMoreImages, + onAddPhotosClicked = viewModel::onPortfolioImagesAdded, + onImageRemoved = viewModel::onPortfolioImageRemoved, + onDescriptionChanged = viewModel::onWorkDescriptionChanged, + onError = { errorMessage -> + viewModel.onImagePickerError(ErrorUiState(errorMessage)) + } , + ) + } + + RegistrationStep.IDENTITY_VERIFICATION -> { + IdentityVerificationPage( + idCardFront = state.idCardFront, + idCardBack = state.idCardBack, + onIdCardSelected = viewModel::onIdCardSelected, + onSkip = viewModel::onSkipIdentityVerification, + onErrorMessage = { errorMessage -> + viewModel.onImagePickerError(ErrorUiState(errorMessage)) + }, + onFrontImageRemoved = viewModel::onFrontIdCardRemoved, + onBackImageRemoved = viewModel::onBackIdCardRemoved, + ) + } + } + } + } + } +} + +@Composable +fun ErrorSnackbar( + error: ErrorUiState, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Snackbar( + modifier = modifier.padding(16.dp), + shape = RoundedCornerShape( 8.dp), + containerColor = AppTheme.craftoColors.additional.primaryRed.copy(alpha = 0.95f), + contentColor = AppTheme.craftoColors.button.onPrimary, + action = { + TextButton( + onClick = onDismiss, text = "Dismiss", + enabled = true, buttonState = ButtonState.Enable + ) + } + ) { + Text( + text = error.message, + style = AppTheme.textStyle.body.medium, + color = AppTheme.craftoColors.button.onPrimary + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupUiState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupUiState.kt new file mode 100644 index 0000000..ac71fa9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupUiState.kt @@ -0,0 +1,80 @@ +package org.example.project.presentation.screens.setup.craftsmansetup + +import org.example.project.domain.entity.VerificationDocuments +import org.example.project.presentation.model.CategoryUi +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.model.PersonalInfoUiModel +import org.example.project.presentation.shared.base.BaseScreenState +import org.example.project.presentation.shared.base.ErrorUiState + +data class CraftsmanSetupUiState( + override val isLoading: Boolean = false, + override val error: ErrorUiState? = null, + + val currentPageIndex: Int = 0, + val totalPages: Int = 5, + val canNavigateNext: Boolean = false, + val canNavigateBack: Boolean = false, + val isSwipeEnabled: Boolean = true, + + val isUploadingProfile: Boolean = false, + val isUploadingPortfolio: Boolean = false, + val isUploadingIdCards: Boolean = false, + val uploadedPortfolioUrls: List = emptyList(), + val verificationDocuments: VerificationDocuments? = null, + + val userType: UserType? = null, + + val availableCategories: List = emptyList(), + val selectedCategoryIds: Set = emptySet(), + + val personalInfo: PersonalInfoUiModel = PersonalInfoUiModel("", "", "", ""), + + val portfolioImages: List = emptyList(), + val workDescription: String = "", + val canAddMoreImages: Boolean = true, + + val idCardFront: ImageData? = null, + val idCardBack: ImageData? = null, + + val profilePicture: ImageData? = null, + val isUploadingProfilePicture: Boolean = false, + val profilePictureUrl: String? = null, + + val craftsmanId: String? = null, + val isProfileCreated: Boolean = false, +): BaseScreenState { + + val currentStep: RegistrationStep + get() = RegistrationStep.fromIndex(currentPageIndex) + + val progress: Float + get() = (currentPageIndex + 1) / totalPages.toFloat() + + val nextButtonText: String + get() = when (currentStep) { + RegistrationStep.USER_TYPE -> "Next" + RegistrationStep.SERVICE_SELECTION -> "Next" + RegistrationStep.PERSONAL_INFO -> if (isLoading) "Creating Profile..." else "Next" + RegistrationStep.PORTFOLIO_UPLOAD -> "Next" + RegistrationStep.IDENTITY_VERIFICATION -> "See Nearby Requests" + } +} + +enum class RegistrationStep(val index: Int) { + USER_TYPE(0), + SERVICE_SELECTION(1), + PERSONAL_INFO(2), + PORTFOLIO_UPLOAD(3), + IDENTITY_VERIFICATION(4); + + companion object { + fun fromIndex(index: Int): RegistrationStep = + entries.firstOrNull { it.index == index } ?: USER_TYPE + } +} + +enum class UserType { + CUSTOMER, + CRAFTSMAN +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupViewModel.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupViewModel.kt new file mode 100644 index 0000000..35e6f2b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/craftsmansetup/CraftsmanSetupViewModel.kt @@ -0,0 +1,458 @@ +package org.example.project.presentation.screens.setup.craftsmansetup + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.example.project.domain.usecase.GetCategoriesUseCase +import org.example.project.domain.usecase.craftsman.CreateCraftsmanProfileUseCase +import org.example.project.domain.usecase.craftsman.UploadIdCardsUseCase +import org.example.project.domain.usecase.craftsman.UploadProfilePictureUseCase +import org.example.project.domain.usecase.craftsman.UploadWorkPortfolioUseCase +import org.example.project.domain.util.AppConstants.FileUpload.MAX_PORTFOLIO_IMAGES +import org.example.project.presentation.model.ImageData +import org.example.project.presentation.model.PersonalInfoUiModel +import org.example.project.presentation.shared.base.BaseViewModel +import org.example.project.presentation.shared.base.ErrorUiState +import org.example.project.presentation.mapper.toDomain +import org.example.project.presentation.mapper.toUi +import org.example.project.presentation.mapper.toWorkImages +import org.example.project.util.AppLogger + +class CraftsmanSetupViewModel( + private val createCraftsmanUseCase: CreateCraftsmanProfileUseCase, + private val uploadIdCardsUseCase: UploadIdCardsUseCase, + private val uploadWorkPortfolioUseCase: UploadWorkPortfolioUseCase, + private val getCategoriesUseCase: GetCategoriesUseCase, + private val uploadProfilePictureUseCase: UploadProfilePictureUseCase, +) : BaseViewModel( + CraftsmanSetupUiState() +), CraftsmanSetupInteractionListener { + + override fun onUserTypeSelected(userType: UserType) { + when (userType) { + UserType.CRAFTSMAN -> { + updateState { + it.copy( + userType = userType, + canNavigateNext = true + ) + } + } + UserType.CUSTOMER -> { + TODO("Implement Customer setup flow – redirect to CustomerSetupScreen when ready") + } + } + } + + init { + validateCurrentPage() + fetchCategories() + viewModelScope.launch { + isLoading.collect { loading -> + updateState { it.copy(isLoading = loading) } + } + } + } + + override fun onCategoryToggled(categoryId: Int) { + updateState { state -> + val newSelection = if (categoryId in state.selectedCategoryIds) { + state.selectedCategoryIds - categoryId + } else { + state.selectedCategoryIds + categoryId + } + state.copy( + selectedCategoryIds = newSelection, + canNavigateNext = newSelection.isNotEmpty() + ) + } + } + + override fun onPersonalInfoChanged(personalInfo: PersonalInfoUiModel) { + updateState { + it.copy( + personalInfo = personalInfo, + canNavigateNext = validatePersonalInfo(personalInfo) + ) + } + } + + override fun onIdCardSelected(isFront: Boolean, imageData: ImageData) { + updateState { state -> + if (isFront) { + state.copy( + idCardFront = imageData, + canNavigateNext = state.idCardBack != null + ) + } else { + state.copy( + idCardBack = imageData, + canNavigateNext = state.idCardFront != null + ) + } + } + } + + override fun onUploadIdCards() { + val craftsmanId = state.value.craftsmanId + val frontCard = state.value.idCardFront + val backCard = state.value.idCardBack + + if (craftsmanId == null) { + updateState { it.copy(error = ErrorUiState("Profile not created yet")) } + return + } + + if (frontCard == null || backCard == null) { + updateState { it.copy(error = ErrorUiState("Please upload both ID card images")) } + return + } + updateState { it.copy(isSwipeEnabled = false) } + + tryToCall( + call = { + uploadIdCardsUseCase( + craftsmanId = craftsmanId, + idCardFront = frontCard.byteArray, + idCardFrontFileName = frontCard.fileName, + idCardBack = backCard.byteArray, + idCardBackFileName = backCard.fileName + ) + }, + onSuccess = { verificationDocs -> + updateState { it.copy(isSwipeEnabled = true) } + sendNewEffect(CraftsmanRegistrationEffect.RegistrationComplete) + }, + onError = { error -> + updateState { + it.copy( + error = error, + isSwipeEnabled = true + ) + } + }, + showLoading = true + ) + } + + override fun onSkipIdentityVerification() { + //sendNewEffect(CraftsmanRegistrationEffect.RegistrationComplete) + navigateNext() + } + + override fun onPortfolioImagesAdded(images: List) { + updateState { state -> + val currentImages = state.portfolioImages + val totalImages = currentImages + images + val limitedImages = totalImages.take(MAX_PORTFOLIO_IMAGES) + + limitedImages.forEachIndexed { index, img -> + AppLogger.d("Portfolio", "Image $index: ${img.fileName}, ${img.byteArray.size} bytes") + } + + state.copy( + portfolioImages = limitedImages, + canAddMoreImages = limitedImages.size < MAX_PORTFOLIO_IMAGES, + canNavigateNext = limitedImages.isNotEmpty() + ) + } + } + + override fun onPortfolioImageRemoved(index: Int) { + updateState { state -> + val newImages = state.portfolioImages.toMutableList().apply { + if (index < this.size) { + removeAt(index) + } + } + val newImagesUrl= state.uploadedPortfolioUrls.toMutableList().apply { + if (index < this.size) { + removeAt(index) + } + } + state.copy( + uploadedPortfolioUrls = newImagesUrl, + portfolioImages = newImages, + canAddMoreImages = true, + canNavigateNext = newImages.isNotEmpty() + ) + } + } + + override fun onProfilePictureRemoved() { + updateState { state -> + state.copy( + profilePicture = null, + profilePictureUrl = null + ) + } + } + + override fun onFrontIdCardRemoved() { + updateState { state -> + state.copy( + idCardFront = null, + canNavigateNext = false + ) + } + } + + override fun onBackIdCardRemoved() { + updateState { state -> + state.copy( + idCardBack = null, + canNavigateNext = false + ) + } + } + + override fun onWorkDescriptionChanged(description: String) { + updateState { state -> + state.copy(workDescription = description) + } + } + + override fun onUploadPortfolio() { + val craftsmanId = state.value.craftsmanId + if (craftsmanId == null) { + updateState { it.copy(error = ErrorUiState("Profile not created yet")) } + return + } + + val portfolioImages = state.value.portfolioImages + if (portfolioImages.isEmpty()) { + return + } + + if (state.value.uploadedPortfolioUrls.isNotEmpty()) { + updateState { it.copy(currentPageIndex = it.currentPageIndex + 1) } + return + } + + updateState { it.copy(isSwipeEnabled = false, isUploadingPortfolio = true) } + + tryToCall( + call = { + val workImages = portfolioImages.toWorkImages() + uploadWorkPortfolioUseCase( + craftsmanId = craftsmanId, + workImages = workImages + ) + }, + onSuccess = { uploadedUrls -> + updateState { + it.copy( + isSwipeEnabled = true, + isUploadingPortfolio = false, + uploadedPortfolioUrls = uploadedUrls, + currentPageIndex = it.currentPageIndex + 1 + ) + } + }, + onError = { error -> + updateState { + it.copy( + error = error, + isSwipeEnabled = true, + isUploadingPortfolio = false + ) + } + }, + showLoading = true + ) + } + + override fun onProfilePictureSelected(imageData: ImageData) { + updateState { state -> + state.copy(profilePicture = imageData) + } + } + + override fun onImagePickerError(error: ErrorUiState) { + updateState { it.copy(error = error) } + } + + private fun uploadProfilePicture(craftsmanId: String, profilePicture: ImageData) { + updateState { it.copy(isUploadingProfilePicture = true) } + + tryToCall( + call = { + uploadProfilePictureUseCase( + craftsmanId = craftsmanId, + profilePicture = profilePicture.byteArray, + profilePictureFileName = profilePicture.fileName + ) + }, + onSuccess = { profilePictureUrl -> + updateState { + it.copy( + profilePictureUrl = profilePictureUrl, + isUploadingProfilePicture = false + ) + } + updateState { + it.copy( + isSwipeEnabled = true, + currentPageIndex = it.currentPageIndex + 1 + ) + } + }, + onError = { error -> + updateState { + it.copy( + error = error, + isUploadingProfilePicture = false + ) + } + updateState { + it.copy( + isSwipeEnabled = true, + currentPageIndex = it.currentPageIndex + 1 + ) + } + }, + showLoading = true + ) + } + + fun clearError() { + updateState { it.copy(error = null) } + } + + fun navigateNext() { + val currentIndex = state.value.currentPageIndex + + when (state.value.currentStep) { + RegistrationStep.PERSONAL_INFO -> { + if (!state.value.isProfileCreated) { + createCraftsmanProfile() + return // createCraftsmanProfile will navigate on success + } + } + RegistrationStep.PORTFOLIO_UPLOAD -> { + val hasImages = state.value.portfolioImages.isNotEmpty() + val alreadyUploaded = state.value.uploadedPortfolioUrls.isNotEmpty() + val isCurrentlyUploading = state.value.isUploadingPortfolio + + if (isCurrentlyUploading) { + return + } + + if (hasImages && !alreadyUploaded) { + onUploadPortfolio() + return // onUploadPortfolio will navigate on success + } + + } + else -> { + // Normal navigation for other steps + } + } + + if (currentIndex < state.value.totalPages - 1 && state.value.canNavigateNext) { + AppLogger.d("Navigation", "Navigating from page $currentIndex to ${currentIndex + 1}") + updateState { it.copy(currentPageIndex = currentIndex + 1) } + } + } + + fun navigateBack() { + val currentIndex = state.value.currentPageIndex + if (currentIndex > 0) { + val targetIndex = if (state.value.isProfileCreated && currentIndex == 3) { + currentIndex // Stay on current page + } else { + currentIndex - 1 + } + updateState { it.copy(currentPageIndex = targetIndex) } + } + } + + fun onPageChanged(pageIndex: Int) { + updateState { + it.copy( + currentPageIndex = pageIndex, + canNavigateBack = pageIndex > 0, + ) + } + validateCurrentPage() + } + + private fun fetchCategories() { + tryToCall( + call = { getCategoriesUseCase() }, + onSuccess = { categories -> + val categoryUiList = categories.map { it.toUi() } + updateState { it.copy(availableCategories = categoryUiList) } + }, + onError = { error -> + updateState { it.copy(error = error) } + }, + showLoading = true + ) + } + + private fun validateCurrentPage() { + val canProceed = when (state.value.currentStep) { + RegistrationStep.USER_TYPE -> state.value.userType != null + RegistrationStep.SERVICE_SELECTION -> state.value.selectedCategoryIds.isNotEmpty() + RegistrationStep.PERSONAL_INFO -> validatePersonalInfo(state.value.personalInfo) + RegistrationStep.PORTFOLIO_UPLOAD -> state.value.portfolioImages.isNotEmpty() + RegistrationStep.IDENTITY_VERIFICATION -> true // Optional step + } + + updateState { it.copy(canNavigateNext = canProceed) } + } + + private fun createCraftsmanProfile() { + val selectedCategoryTitles = state.value.availableCategories + .filter { it.id in state.value.selectedCategoryIds } + .map { it.title } + + updateState { it.copy(isSwipeEnabled = false) } + + tryToCall( + call = { + createCraftsmanUseCase( + personalInfo = state.value.personalInfo.toDomain(), + categories = selectedCategoryTitles + ) + }, + onSuccess = { craftsmanId -> + updateState { + it.copy( + craftsmanId = craftsmanId, + isProfileCreated = true + ) + } + + val profilePicture = state.value.profilePicture + if (profilePicture != null) { + uploadProfilePicture(craftsmanId, profilePicture) + } else { + updateState { + it.copy( + isSwipeEnabled = true, + currentPageIndex = it.currentPageIndex + 1 + ) + } + } + }, + onError = { error -> + updateState { + it.copy( + error = error, + isSwipeEnabled = true + ) + } + }, + showLoading = true + ) + } + + private fun validatePersonalInfo(info: PersonalInfoUiModel): Boolean { + return info.firstName.length >= 3 && + info.lastName.length >= 3 && + info.phoneNumber.length >= 10 && + info.phoneNumber.matches(Regex("^\\+?[1-9]\\d{1,14}$")) && + info.address.isNotBlank() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/customersetup/init b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/customersetup/init new file mode 100644 index 0000000..6a8de1b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/customersetup/init @@ -0,0 +1 @@ +TODO() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/AccountSetupTopBar.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/AccountSetupTopBar.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/AccountSetupTopBar.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/AccountSetupTopBar.kt index 28fd946..5d87caa 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/AccountSetupTopBar.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/AccountSetupTopBar.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.screens.setupScreens.location +package org.example.project.presentation.screens.setup.location import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationEffect.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationEffect.kt similarity index 67% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationEffect.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationEffect.kt index 2d5e88b..b571c42 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationEffect.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationEffect.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.screens.setupScreens.location +package org.example.project.presentation.screens.setup.location sealed class LocationEffect { object NavigateToNextScreen : LocationEffect() diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationSetupScreen.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationSetupScreen.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationSetupScreen.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationSetupScreen.kt index bef097b..4548566 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationSetupScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationSetupScreen.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.screens.setupScreens.location +package org.example.project.presentation.screens.setup.location import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationUiState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationUiState.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationUiState.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationUiState.kt index f403a7d..70c6db6 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationUiState.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationUiState.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.screens.setupScreens.location +package org.example.project.presentation.screens.setup.location import org.example.project.domain.entity.District import org.example.project.domain.entity.Governorates diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationViewModel.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationViewModel.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationViewModel.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationViewModel.kt index 1fea3cb..1af1fca 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/location/LocationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setup/location/LocationViewModel.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.screens.setupScreens.location +package org.example.project.presentation.screens.setup.location import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupCategoryScreen.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupCategoryScreen.kt deleted file mode 100644 index 054fc87..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupCategoryScreen.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.example.project.presentation.ui.screens.setupScreens - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import crafto.composeapp.generated.resources.Res -import crafto.composeapp.generated.resources.account_setup_craftsman_category_description -import crafto.composeapp.generated.resources.account_setup_craftsman_category_title -import crafto.composeapp.generated.resources.account_setup_customer_category_description -import crafto.composeapp.generated.resources.account_setup_customer_category_title -import org.example.project.presentation.ui.screens.setupScreens.component.CategoryActionBox -import org.example.project.presentation.ui.screens.setupScreens.component.SetupScreenScaffold -import org.example.project.presentation.screens.setupScreens.AccountSetupState -import org.example.project.presentation.screens.setupScreens.AccountSetupViewModel -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.KoinExperimentalAPI - -@OptIn(KoinExperimentalAPI::class) -@Composable -fun AccountSetupCategoryScreen( - viewModel: AccountSetupViewModel = koinViewModel(), -) { - val state by viewModel.state.collectAsState() - AccountSetupCategoryContent( - state = state, - isCustomer = true, - currentPageNumber = 2, - onBackButtonClick = {}, - onNextButtonClick = {}, - onChipSelected = viewModel::onCategorySelected - ) -} - -@Composable -fun AccountSetupCategoryContent( - modifier: Modifier = Modifier, - state: AccountSetupState, - isCustomer: Boolean, - currentPageNumber: Int, - onBackButtonClick: () -> Unit, - onNextButtonClick: () -> Unit, - onChipSelected: (id: Int) -> Unit, -) { - SetupScreenScaffold( - modifier = modifier, - currentPageNumber = currentPageNumber, - title = selectCustomerOrCraftsmanText(isCustomer).first, - description = selectCustomerOrCraftsmanText(isCustomer).second, - onBackButtonClick = onBackButtonClick, - onNextButtonClick = onNextButtonClick, - ) { - CategoryActionBox( - state = state, - onChipSelected = onChipSelected, - ) - } - -} - -@Composable -private fun selectCustomerOrCraftsmanText(isCustomer: Boolean): Pair { - return if (isCustomer) { - stringResource(Res.string.account_setup_customer_category_title) to - stringResource(Res.string.account_setup_customer_category_description) - } else { - stringResource(Res.string.account_setup_craftsman_category_title) to - stringResource(Res.string.account_setup_craftsman_category_description) - } -} - - - - diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupEffect.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupEffect.kt deleted file mode 100644 index e98c9ec..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupEffect.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.project.presentation.screens.setupScreens - -sealed class AccountSetupEffect { - -} diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupInterActionListener.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupInterActionListener.kt deleted file mode 100644 index 4bf163e..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupInterActionListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.project.presentation.screens.setupScreens - -interface AccountSetupInterActionListener { - fun onCategorySelected(id: Int) -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupState.kt deleted file mode 100644 index b9eb309..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.example.project.presentation.screens.setupScreens - -import org.example.project.domain.entity.Category - -data class AccountSetupState( - val title: String = "", - val description: String = "", - val isLoading: Boolean = false, - val isError: Boolean = false, - val categoryState: AccountSetupCategoryState = AccountSetupCategoryState() -) - -data class AccountSetupCategoryState( - val categories: List = emptyList(), - val isSelected: Boolean = false, -) diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupViewModel.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupViewModel.kt deleted file mode 100644 index 5041221..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/AccountSetupViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.example.project.presentation.screens.setupScreens - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch -import org.example.project.domain.usecase.GetCategoriesUseCase -import org.example.project.presentation.shared.base.BaseViewModel -import org.koin.android.annotation.KoinViewModel -import org.koin.core.annotation.Provided - -@KoinViewModel -class AccountSetupViewModel( - @Provided private val getCategoriesUseCase : GetCategoriesUseCase -) : BaseViewModel(AccountSetupState()), - AccountSetupInterActionListener { - - init { - fetchCategories() - } - - private fun fetchCategories() { - viewModelScope.launch { - val categories = getCategoriesUseCase.invoke() - updateState { - it.copy( - categoryState = it.categoryState.copy(categories = categories) - ) - } - } - } - - override fun onCategorySelected(id: Int) { - updateState { - val categories = it.categoryState.categories.map { category -> - if (category.id == id) { - category.copy(isSelected = !category.isSelected) - } else { - category - } - } - it.copy( - categoryState = it.categoryState.copy(categories = categories) - ) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/CategoryActionBox.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/CategoryActionBox.kt deleted file mode 100644 index dddeb96..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/setupScreens/component/CategoryActionBox.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.example.project.presentation.ui.screens.setupScreens.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import org.example.project.data.memory.dataSource.categoryList -import org.example.project.presentation.designsystem.components.Chip -import org.example.project.presentation.designsystem.textstyle.AppTheme -import org.example.project.presentation.util.extension.toAnimatedColor -import org.example.project.presentation.screens.setupScreens.AccountSetupCategoryState -import org.example.project.presentation.screens.setupScreens.AccountSetupState -import org.jetbrains.compose.ui.tooling.preview.Preview - -@Composable -fun CategoryActionBox( - modifier: Modifier = Modifier, - state: AccountSetupState, - onChipSelected: (id: Int) -> Unit, -) { - Box(modifier = modifier.fillMaxWidth()) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - - state.categoryState.categories.forEach { category -> - val isSelected = category.isSelected - Chip( - text = category.title, - isSelected = isSelected, - onChipSelected = { onChipSelected(category.id) }, - modifier = Modifier.background( - color = category.color.toAnimatedColor( - isSelected, - AppTheme.craftoColors.background.card - ), - shape = RoundedCornerShape(AppTheme.craftoRadius.full) - ), - textColor = AppTheme.craftoColors.background.card - .toAnimatedColor( - isSelected, - AppTheme.craftoColors.shade.secondary - ), - borderColor = category.color.toAnimatedColor( - condition = isSelected, - falseConditionColor = Color.Transparent, - duration = 100 - - ) - ) - } - } - } -} - -@Preview -@Composable -fun CategoryActionBoxLightPreview() { - AppTheme { - CategoryActionBox( - state = AccountSetupState( - categoryState = AccountSetupCategoryState( - categories = categoryList - ) - ), - onChipSelected = {} - ) - - } -} - -@Preview -@Composable -fun CategoryActionBoxDarkPreview() { - AppTheme(isDarkTheme = true) { - CategoryActionBox( - state = AccountSetupState( - categoryState = AccountSetupCategoryState( - categories = categoryList - ) - ), - onChipSelected = {} - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseScreenState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseScreenState.kt new file mode 100644 index 0000000..cadaca9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseScreenState.kt @@ -0,0 +1,6 @@ +package org.example.project.presentation.shared.base + +interface BaseScreenState { + val isLoading: Boolean + val error: ErrorUiState? +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseViewModel.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseViewModel.kt index b728352..28da96d 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/BaseViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.example.project.presentation.mapper.toErrorUiState abstract class BaseViewModel( initialState: SCREEN_STATE, @@ -21,24 +22,29 @@ abstract class BaseViewModel( protected val _effect = MutableSharedFlow() val effect = _effect.asSharedFlow() + protected val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + protected fun tryToCall( call: suspend () -> T, onSuccess: (T) -> Unit, onError: (error: ErrorUiState) -> Unit, + showLoading: Boolean = false, dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { viewModelScope.launch(dispatcher) { + if (showLoading) _isLoading.value = true try { val result = call() onSuccess(result) } catch (e: Exception) { - onError(ErrorUiState(e.message ?: "Unknown error")) + onError(e.toErrorUiState()) + } finally { + if (showLoading) _isLoading.value = false } } } - - protected fun updateState(updater: (SCREEN_STATE) -> SCREEN_STATE) = _state.update(updater) protected fun sendNewEffect(newEffect: SCREEN_EFFECT) { diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/ErrorUiState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/ErrorUiState.kt index e5b461c..73a6de7 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/ErrorUiState.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/shared/base/ErrorUiState.kt @@ -2,4 +2,13 @@ package org.example.project.presentation.shared.base data class ErrorUiState ( val message: String = "", -) + val errorType: ErrorType = ErrorType.UNKNOWN, +){ + enum class ErrorType { + NETWORK, + AUTHENTICATION, + VALIDATION, + SERVER, + UNKNOWN + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/util/ImagePickerUtils.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/util/ImagePickerUtils.kt new file mode 100644 index 0000000..c7a75d7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/util/ImagePickerUtils.kt @@ -0,0 +1,144 @@ +package org.example.project.presentation.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.mohamedrejeb.calf.core.LocalPlatformContext +import com.mohamedrejeb.calf.io.getName +import com.mohamedrejeb.calf.io.readByteArray +import com.mohamedrejeb.calf.picker.FilePickerFileType +import com.mohamedrejeb.calf.picker.FilePickerSelectionMode +import com.mohamedrejeb.calf.picker.rememberFilePickerLauncher +import kotlinx.coroutines.launch +import org.example.project.presentation.model.ImageData +import org.example.project.domain.util.AppConstants +import org.example.project.util.AppLogger +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class ImagePickerLauncher( + private val launch: () -> Unit +) { + fun launch() = launch.invoke() +} + +@OptIn(ExperimentalTime::class) +@Composable +fun rememberImagePicker( + singleSelection: Boolean = true, + onImagesSelected: (List) -> Unit, + onError: (String) -> Unit = {} +): ImagePickerLauncher { + val scope = rememberCoroutineScope() + val context = LocalPlatformContext.current + + val launcher = rememberFilePickerLauncher( + type = FilePickerFileType.Image, + selectionMode = if (singleSelection) { + FilePickerSelectionMode.Single + } else { + FilePickerSelectionMode.Multiple + } + ) { files -> + scope.launch { + try { + AppLogger.d("ImagePicker", " Files selected: ${files.size}") + + val imageDataList = files.mapNotNull { file -> + try { + val originalFileName = file.getName(context) + AppLogger.d("ImagePicker", "Processing file: $originalFileName") + + val byteArray = file.readByteArray(context) + AppLogger.d("ImagePicker", " Size: ${byteArray.size} bytes") + + // Basic validation + if (byteArray.size > AppConstants.FileUpload.MAX_FILE_SIZE) { + onError("Image is too large. Maximum size is ${AppConstants.FileUpload.MAX_FILE_SIZE_MB}MB") + AppLogger.e("ImagePicker", " File too large!") + return@mapNotNull null + } + + // CRITICAL: Ensure proper file extension + val fileName = ensureProperFileName(originalFileName, byteArray) + AppLogger.d("ImagePicker", " Final fileName: $fileName") + + // Validate extension + val extension = fileName.substringAfterLast('.', "").lowercase() + if (extension !in AppConstants.FileUpload.ALLOWED_IMAGE_TYPES) { + AppLogger.e("ImagePicker", " Invalid extension: $extension") + onError("Invalid image format. Please select JPG or PNG") + return@mapNotNull null + } + + AppLogger.d("ImagePicker", " File validated successfully") + + ImageData( + uri = fileName, + fileName = fileName, + byteArray = byteArray + ) + } catch (e: Exception) { + AppLogger.e("ImagePicker", " Error processing file: ${e.message}") + e.printStackTrace() + null + } + } + + if (imageDataList.isNotEmpty()) { + AppLogger.d("ImagePicker", " Successfully processed ${imageDataList.size} images") + onImagesSelected(imageDataList) + } else { + AppLogger.d("ImagePicker", "⚠ No valid images processed") + } + } catch (e: Exception) { + AppLogger.e("ImagePicker", " Failed to pick images: ${e.message}") + onError("Failed to pick images") + } + } + } + + return remember { + ImagePickerLauncher { launcher.launch() } + } +} + +@OptIn(ExperimentalTime::class) +private fun ensureProperFileName(originalFileName: String?, byteArray: ByteArray): String { + // If we have a filename with valid extension, use it + if (!originalFileName.isNullOrBlank()) { + val extension = originalFileName.substringAfterLast('.', "").lowercase() + if (extension in AppConstants.FileUpload.ALLOWED_IMAGE_TYPES) { + return originalFileName + } + + val detectedExt = detectImageFormat(byteArray) + val baseFileName = originalFileName.substringBeforeLast('.', originalFileName) + return "$baseFileName.$detectedExt" + } + + val extension = detectImageFormat(byteArray) + return "image_${Clock.System.now().toEpochMilliseconds()}.$extension" +} + + +private fun detectImageFormat(byteArray: ByteArray): String { + if (byteArray.size < 4) return "jpg" + + // PNG signature: 89 50 4E 47 + if (byteArray[0] == 0x89.toByte() && + byteArray[1] == 0x50.toByte() && + byteArray[2] == 0x4E.toByte() && + byteArray[3] == 0x47.toByte()) { + return "png" + } + + // JPEG signature: FF D8 FF + if (byteArray[0] == 0xFF.toByte() && + byteArray[1] == 0xD8.toByte() && + byteArray[2] == 0xFF.toByte()) { + return "jpg" + } + + return "jpg" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/util/AppLogger.kt b/composeApp/src/commonMain/kotlin/org/example/project/util/AppLogger.kt new file mode 100644 index 0000000..2fb4db0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/util/AppLogger.kt @@ -0,0 +1,7 @@ +package org.example.project.util + +expect object AppLogger { + fun e(tag: String, message: String, throwable: Throwable? = null) + fun d(tag: String, message: String) + fun i(tag: String, message: String) +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/org/example/project/CraftsmanRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/example/project/CraftsmanRepositoryTest.kt new file mode 100644 index 0000000..719abaf --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/example/project/CraftsmanRepositoryTest.kt @@ -0,0 +1,116 @@ +package org.example.project + +import kotlinx.coroutines.test.runTest +import org.example.project.domain.entity.Craftsman +import org.example.project.domain.entity.CraftsmanStatus +import org.example.project.domain.entity.PersonalInfo +import org.example.project.domain.entity.VerificationDocuments +import org.example.project.domain.exception.ValidationException +import org.example.project.domain.model.WorkImage +import org.example.project.domain.repository.CraftsmanRepository +import org.example.project.domain.usecase.craftsman.CreateCraftsmanProfileUseCase +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + + +class CreateCraftsmanProfileUseCaseTest { + + private lateinit var useCase: CreateCraftsmanProfileUseCase + private lateinit var mockRepository: TestCraftsmanRepository + + @BeforeTest + fun setup() { + mockRepository = TestCraftsmanRepository() + useCase = CreateCraftsmanProfileUseCase(mockRepository) + } + + @Test + fun `invoke succeeds with valid input`() = runTest { + // Given + val personalInfo = PersonalInfo( + firstName = "John", + lastName = "Doe", + phoneNumber = "+1234567890", + address = "123 Test St" + ) + + // When + val result = useCase(personalInfo, listOf("plumbing")) + + // Then + assertEquals("craftsman123", result) + } + + @Test + fun `invoke throws ValidationException for empty categories`() = runTest { + // Given + val personalInfo = PersonalInfo( + firstName = "John", + lastName = "Doe", + phoneNumber = "+1234567890", + address = "123 Test St" + ) + + // When/Then + val exception = assertFailsWith { + useCase(personalInfo, emptyList()) + } + assertEquals("Please select at least one service category", exception.message) + } + + @Test + fun `invoke throws ValidationException for invalid phone`() = runTest { + // Given + val personalInfo = PersonalInfo( + firstName = "John", + lastName = "Doe", + phoneNumber = "invalid", + address = "123 Test St" + ) + + // When/Then + assertFailsWith { + useCase(personalInfo, listOf("plumbing")) + } + } +} + +class TestCraftsmanRepository : CraftsmanRepository { + override suspend fun createCraftsmanProfile( + personalInfo: PersonalInfo, + categories: List + ): String { + return "craftsman123" + } + + override suspend fun uploadIdCards( + craftsmanId: String, + idCardFront: ByteArray, + idCardFrontFileName: String, + idCardBack: ByteArray, + idCardBackFileName: String + ): VerificationDocuments { + TODO("Not yet implemented") + } + + override suspend fun uploadWorkPortfolio( + craftsmanId: String, + workImages: List + ): List { + TODO("Not yet implemented") + } + + override suspend fun getCraftsmanProfile(): Craftsman { + TODO("Not yet implemented") + } + + override suspend fun getCraftsmanStatus(craftsmanId: String): CraftsmanStatus { + TODO("Not yet implemented") + } + + override suspend fun deleteCraftsmanAccount(craftsmanId: String) { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/example/project/MainViewController.kt b/composeApp/src/iosMain/kotlin/org/example/project/MainViewController.kt index a04ca7d..680f866 100644 --- a/composeApp/src/iosMain/kotlin/org/example/project/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/org/example/project/MainViewController.kt @@ -1,10 +1,13 @@ package org.example.project import androidx.compose.ui.window.ComposeUIViewController -import initKoin +import org.example.project.di.initKoin +import org.example.project.di.iosModule fun MainViewController() = ComposeUIViewController( configure = { - initKoin() + initKoin { + modules(iosModule) + } } ) { App() } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/example/project/data/local/datasource/StorageLocalDataSourceImpl.kt b/composeApp/src/iosMain/kotlin/org/example/project/data/local/datasource/StorageLocalDataSourceImpl.kt new file mode 100644 index 0000000..40a3bfd --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/example/project/data/local/datasource/StorageLocalDataSourceImpl.kt @@ -0,0 +1,72 @@ +package org.example.project.data.local.datasource + +import platform.Foundation.NSUserDefaults + +class StorageLocalDataSourceImpl : StorageLocalDataSource { + + private val userDefaults = NSUserDefaults.standardUserDefaults + + override suspend fun saveString(key: String, value: String) { + userDefaults.setObject(value, forKey = key) + } + + override suspend fun getString(key: String): String? { + return userDefaults.stringForKey(key) + } + + override suspend fun saveStringSet(key: String, values: Set) { + val list = values.toList() + userDefaults.setObject(list, forKey = key) + } + + override suspend fun getStringSet(key: String): Set? { + val array = userDefaults.objectForKey(key) as? List<*> + return array?.mapNotNull { it as? String }?.toSet() + } + + override suspend fun saveLong(key: String, value: Long) { + // NSUserDefaults uses NSInteger (Long on 64-bit platforms) + userDefaults.setInteger(value, forKey = key) + } + + override suspend fun getLong(key: String): Long? { + return if (userDefaults.objectForKey(key) != null) { + userDefaults.integerForKey(key) + } else { + null + } + } + + override suspend fun saveBoolean(key: String, value: Boolean) { + userDefaults.setBool(value, forKey = key) + } + + override suspend fun getBoolean(key: String): Boolean? { + return if (userDefaults.objectForKey(key) != null) { + userDefaults.boolForKey(key) + } else { + null + } + } + + override suspend fun remove(key: String) { + userDefaults.removeObjectForKey(key) + } + + override suspend fun removeAll() { + // Get the app's bundle identifier to only remove app-specific keys + val dictionary = userDefaults.dictionaryRepresentation() + dictionary.keys.forEach { key -> + (key as? String)?.let { + userDefaults.removeObjectForKey(it) + } + } + userDefaults.synchronize() + } + + override suspend fun getAllKeys(): Set { + return userDefaults.dictionaryRepresentation().keys + .mapNotNull { it as? String } + .toSet() + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/example/project/di/IosModule.kt b/composeApp/src/iosMain/kotlin/org/example/project/di/IosModule.kt new file mode 100644 index 0000000..264c6ff --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/example/project/di/IosModule.kt @@ -0,0 +1,12 @@ +package org.example.project.di + + +import org.example.project.data.local.datasource.StorageLocalDataSource +import org.example.project.data.local.datasource.StorageLocalDataSourceImpl +import org.koin.dsl.module + +val iosModule = module { + single { + StorageLocalDataSourceImpl() + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/example/project/di/getHttpEngine.ios.kt b/composeApp/src/iosMain/kotlin/org/example/project/di/getHttpEngine.ios.kt index fed8979..666e2ea 100644 --- a/composeApp/src/iosMain/kotlin/org/example/project/di/getHttpEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/example/project/di/getHttpEngine.ios.kt @@ -3,4 +3,8 @@ package org.example.project.di import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.darwin.Darwin -actual fun getHttpEngine(): HttpClientEngine = Darwin.create() \ No newline at end of file +actual fun getHttpEngine(): HttpClientEngine = Darwin.create{ + configureRequest { + setAllowsCellularAccess(true) + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/example/project/util/AppLogger.kt b/composeApp/src/iosMain/kotlin/org/example/project/util/AppLogger.kt new file mode 100644 index 0000000..e25bea0 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/example/project/util/AppLogger.kt @@ -0,0 +1,22 @@ +package org.example.project.util + +import platform.Foundation.NSLog + +actual object AppLogger { + actual fun e(tag: String, message: String, throwable: Throwable?) { + + if (throwable != null) { + NSLog("ERROR: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}") + } else { + NSLog("ERROR: [$tag] $message") + } + } + + actual fun d(tag: String, message: String) { + NSLog("DEBUG: [$tag] $message") + } + + actual fun i(tag: String, message: String) { + NSLog("INFO: [$tag] $message") + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46e4f10..eee6826 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -adaptive = "1.2.0-alpha06" +adaptive = "1.2.0-alpha07" agp = "8.13.0" android-compileSdk = "36" android-minSdk = "24" @@ -8,34 +8,40 @@ androidx-activity = "1.11.0" androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.9.3" +androidx-lifecycle = "2.9.4" androidx-testExt = "1.3.0" +calfFilePicker = "0.8.0" coilComposeVersion = "3.3.0" -composeMultiplatform = "1.8.2" +composeMultiplatform = "1.9.0" +datastorePreferences = "1.1.7" junit = "4.13.2" kotlin = "2.2.20" +ksp = "2.2.20-2.0.4" +koinTest = "4.1.1" koin = "4.1.1" +koinAnnotations = "2.2.0" +ktor = "3.3.1" coil = "3.3.0" koinComposeMultiplatform = "4.1.1" -ksp = "2.2.10-2.0.2" -koin-annotations = "2.1.0" -ktor = "3.2.3" -kotlinx-serialization = "1.5.1" +kotlinxCoroutinesTest = "1.10.2" +kotlinxDatetime = "0.7.1" +kotlinx-serialization = "1.9.0" - - -google-services = "4.4.3" +google-services = "4.4.4" #newer version of firebase-bom causes gradle errors firebase-bom = "33.16.0" firebaseCrashlytics = "3.0.6" -ktorClientCio = "3.3.0" +ktorClientCio = "3.3.1" +ktorClientMock = "3.3.1" [libraries] adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "adaptive" } adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" } adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" } -coil3-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilComposeVersion" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +calf-file-picker = { module = "com.mohamedrejeb.calf:calf-file-picker", version.ref = "calfFilePicker" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koinTest" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { module = "junit:junit", version.ref = "junit" } @@ -52,16 +58,20 @@ firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics- #koin koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } -koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-annotations" } -koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-annotations" } +koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koinAnnotations" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koinAnnotations" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" } # ktor +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClientCio" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktorClientMock" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }