diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2011516..bf0c799 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,4 +1,3 @@ - import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask @@ -11,6 +10,7 @@ plugins { alias(libs.plugins.google.services) alias(libs.plugins.firebase.crashlytics) alias(libs.plugins.ksp) + alias(libs.plugins.kotlinx.serialization) } kotlin { @@ -41,6 +41,11 @@ kotlin { implementation(libs.firebase.analytics) implementation(project.dependencies.platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics.ktx) + + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.android) + } commonMain.dependencies { implementation(compose.runtime) @@ -60,15 +65,29 @@ kotlin { api(libs.koin.annotations) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + + implementation(libs.bundles.coil) + + } commonTest.dependencies { implementation(libs.kotlin.test) } + + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } } sourceSets.named("commonMain").configure { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") } + } ksp { @@ -107,11 +126,11 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + } dependencies { debugImplementation(compose.uiTooling) add("kspCommonMainMetadata", libs.koin.ksp.compiler) - } diff --git a/composeApp/src/androidMain/kotlin/org/example/project/di/getHttpEngine.android.kt b/composeApp/src/androidMain/kotlin/org/example/project/di/getHttpEngine.android.kt new file mode 100644 index 0000000..596c727 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/example/project/di/getHttpEngine.android.kt @@ -0,0 +1,6 @@ +package org.example.project.di + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.cio.CIO + +actual fun getHttpEngine(): HttpClientEngine = CIO.create() \ 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 bda642c..1236764 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/App.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/App.kt @@ -2,13 +2,13 @@ package org.example.project import androidx.compose.runtime.Composable import org.example.project.presentation.designsystem.textstyle.AppTheme -import org.example.project.presentation.ui.screens.setupScreens.AccountSetupCategoryScreen +import org.example.project.presentation.screens.onboarding.OnboardingScreen import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @Preview fun App() { AppTheme { - AccountSetupCategoryScreen() + OnboardingScreen() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/dto/OnboardingDto.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/dto/OnboardingDto.kt new file mode 100644 index 0000000..3b355fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/dto/OnboardingDto.kt @@ -0,0 +1,16 @@ +package org.example.project.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OnboardingDto( + @SerialName("id") + val id : String, + @SerialName("title") + val title : String, + @SerialName("imageRes") + val imageUrl : String, + @SerialName("description") + val description : String +) 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 new file mode 100644 index 0000000..ce1da95 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/mapper/OnboardingMapper.kt @@ -0,0 +1,11 @@ +package org.example.project.data.mapper + +import org.example.project.data.dto.OnboardingDto +import org.example.project.domain.entity.OnboardingItem + +fun OnboardingDto.toEntity() : OnboardingItem = + OnboardingItem( + imageRes = imageUrl, + title = title, + description = description + ) \ 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 new file mode 100644 index 0000000..84365bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/repository/OnboardingRepositoryImp.kt @@ -0,0 +1,25 @@ +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.mapper.toEntity +import org.example.project.data.utils.NetworkConstants.ONBOARDING_END_POINT +import org.example.project.data.utils.safeApiCall +import org.example.project.domain.entity.OnboardingItem +import org.example.project.domain.repository.OnboardingRepository +import org.koin.core.annotation.Provided +import org.koin.core.annotation.Single + + +@Single(binds = [OnboardingRepository::class]) +class OnboardingRepositoryImp( + @Provided private val httpClient: HttpClient +) : OnboardingRepository { + + override suspend fun getOnboardingData(): List { + return safeApiCall> { + httpClient.get(ONBOARDING_END_POINT) + }.map { it.toEntity() } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/data/utils/NetworkConstants.kt b/composeApp/src/commonMain/kotlin/org/example/project/data/utils/NetworkConstants.kt new file mode 100644 index 0000000..9c7d8c5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/utils/NetworkConstants.kt @@ -0,0 +1,5 @@ +package org.example.project.data.utils + +object NetworkConstants { + const val ONBOARDING_END_POINT = "/onboarding" +} \ 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 new file mode 100644 index 0000000..f0a034f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/data/utils/safeApiCall.kt @@ -0,0 +1,92 @@ +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/NetworkModule.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/NetworkModule.kt new file mode 100644 index 0000000..110d1ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/NetworkModule.kt @@ -0,0 +1,53 @@ +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 + +//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 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/di/getHttpEngine.kt b/composeApp/src/commonMain/kotlin/org/example/project/di/getHttpEngine.kt new file mode 100644 index 0000000..f0e2eeb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/getHttpEngine.kt @@ -0,0 +1,5 @@ +package org.example.project.di + +import io.ktor.client.engine.HttpClientEngine + +expect fun getHttpEngine(): HttpClientEngine \ 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 efae486..a4aba07 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/di/initKoin.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/di/initKoin.kt @@ -7,6 +7,6 @@ import org.koin.ksp.generated.module fun initKoin(config: KoinAppDeclaration? = null) { startKoin { config?.invoke(this) - modules(CraftoModule().module) + modules(CraftoModule().module, NetworkModule().module) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/OnboardingItem.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/OnboardingItem.kt new file mode 100644 index 0000000..8bdd5b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/entity/OnboardingItem.kt @@ -0,0 +1,7 @@ +package org.example.project.domain.entity + +data class OnboardingItem( + val imageRes : String, + val title : String, + val description : String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/OnboardingRepository.kt b/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/OnboardingRepository.kt new file mode 100644 index 0000000..ee2dc6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/domain/repository/OnboardingRepository.kt @@ -0,0 +1,7 @@ +package org.example.project.domain.repository + +import org.example.project.domain.entity.OnboardingItem + +interface OnboardingRepository { + suspend fun getOnboardingData(): List +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnBoardingScreen.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnBoardingScreen.kt deleted file mode 100644 index d80dc07..0000000 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnBoardingScreen.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.example.project.presentation.screens.onboarding - -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -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.get_started -import crafto.composeapp.generated.resources.next -import crafto.composeapp.generated.resources.onboarding1 -import crafto.composeapp.generated.resources.onboarding2 -import crafto.composeapp.generated.resources.onboarding3 -import crafto.composeapp.generated.resources.skip -import kotlinx.coroutines.launch -import org.example.project.presentation.designsystem.components.ButtonState -import org.example.project.presentation.designsystem.components.PrimaryButton -import org.example.project.presentation.designsystem.components.SecondaryButton -import org.example.project.presentation.designsystem.textstyle.AppTheme -import org.example.project.presentation.screens.onboarding.composable.OnBoardingIndicator -import org.example.project.presentation.screens.onboarding.composable.OnBoardingItem -import org.example.project.presentation.screens.onboarding.composable.OnBoardingPage -import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview - -@Composable -fun OnBoardingScreen( - onBoardingPagesContent: List -) { - OnBoardingContent( - onSkipButtonClick = {}, - onGetStartButtonClick = {}, - onBoardingPagesContent = onBoardingPagesContent - ) -} - -@Composable -fun OnBoardingContent( - onBoardingPagesContent: List, - onSkipButtonClick: () -> Unit, - onGetStartButtonClick: () -> Unit, - modifier: Modifier = Modifier -) { - val pagerState = - rememberPagerState(initialPage = 0, pageCount = { onBoardingPagesContent.size }) - val coroutineScope = rememberCoroutineScope() - val buttonText = - if (pagerState.currentPage == onBoardingPagesContent.size - 1) Res.string.get_started else Res.string.next - - - Column( - modifier = modifier.fillMaxSize() - .background(AppTheme.craftoColors.background.screen) - .padding(horizontal = 16.dp) - .systemBarsPadding() - .verticalScroll(rememberScrollState()) - ) { - Box( - modifier = Modifier.padding(top = 24.dp).align(Alignment.End), - ) { - SecondaryButton( - text = stringResource(Res.string.skip), - enabled = true, - onClick = { - val skipPage = pagerState.pageCount - 1 - coroutineScope.launch { - pagerState.animateScrollToPage( - page = skipPage, - animationSpec = spring( - dampingRatio = 0.85f, - stiffness = 44f - ) - ) - } - onSkipButtonClick() - }, - buttonState = ButtonState.Enable, - containerColor = AppTheme.craftoColors.button.secondary, - contentPadding = PaddingValues(vertical = 14.dp, horizontal = 24.dp) - ) - } - - HorizontalPager( - state = pagerState, - modifier = Modifier.padding(vertical = 32.dp) - ) { page -> - OnBoardingItem(onBoardingPagesContent[page]) - } - - Row( - modifier = Modifier, - verticalAlignment = Alignment.CenterVertically - ) { - OnBoardingIndicator( - currentPage = pagerState.currentPage, - totalPage = pagerState.pageCount, - progressColor = AppTheme.craftoColors.brand.primary, - trackColor = AppTheme.craftoColors.shade.quaternary, - modifier = Modifier.width(100.dp) - ) - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton( - text = stringResource(buttonText), - enabled = true, - onClick = { - if (pagerState.currentPage < onBoardingPagesContent.size - 1) { - val nextPage = pagerState.currentPage + 1 - coroutineScope.launch { - pagerState.animateScrollToPage( - page = nextPage, - animationSpec = spring( - dampingRatio = 0.85f, - stiffness = 440f - ) - ) - } - } else { - onGetStartButtonClick() - } - }, - buttonState = ButtonState.Enable, - modifier = Modifier.animateContentSize( - animationSpec = spring( - dampingRatio = 0.85f, - stiffness = 44f, - ), - alignment = Alignment.BottomEnd - ), - contentPadding = PaddingValues(vertical = 14.dp, horizontal = 24.dp) - ) - } - } -} - - -@Preview -@Composable -private fun OnBoardingScreenPreview() { - AppTheme(isDarkTheme = false) { - OnBoardingContent( - onSkipButtonClick = {}, - onGetStartButtonClick = {}, - onBoardingPagesContent = listOf( - OnBoardingPage( - imageRes = Res.drawable.onboarding1, - title = "Relax, We’ve Got It Covered", - description = "From the comfort of your couch, post your request and let trusted professionals come to you, no calls, no stress." - ), - OnBoardingPage( - imageRes = Res.drawable.onboarding2, - title = "Find What You Need in Seconds", - description = "Browse dozens of home services — from quick fixes to big projects. Just tap a category and get started instantly." - ), - OnBoardingPage( - imageRes = Res.drawable.onboarding3, - title = "Post, Compare Offers and Choose!", - description = "Receive multiple offers from nearby professionals, check their prices and ratings, then pick the one that suits you best." - ), - ) - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..ef38cc8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreen.kt @@ -0,0 +1,200 @@ +package org.example.project.presentation.screens.onboarding + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +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.get_started +import crafto.composeapp.generated.resources.next +import crafto.composeapp.generated.resources.skip +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.example.project.presentation.designsystem.components.ButtonState +import org.example.project.presentation.designsystem.components.PrimaryButton +import org.example.project.presentation.designsystem.components.SecondaryButton +import org.example.project.presentation.designsystem.textstyle.AppTheme +import org.example.project.presentation.screens.onboarding.composable.OnboardingIndicator +import org.example.project.presentation.screens.onboarding.composable.OnboardingItem +import org.example.project.presentation.viewmodel.OnboardingViewModel +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun OnboardingScreen( + viewModel: OnboardingViewModel = koinViewModel() + +) { + val state by viewModel.state.collectAsState() + OnboardingContent( + state = state, + interactions = viewModel + ) +} + +@Composable +private fun OnboardingContent( + state: OnboardingScreenState, + interactions: OnboardingScreenInteractionListener, +) { + val pagerState = rememberPagerState(initialPage = 0, pageCount = { state.onboardingData.size }) + val coroutineScope = rememberCoroutineScope() + val buttonText = if (pagerState.currentPage == state.onboardingData.size - 1) Res.string.get_started else Res.string.next + + Column( + modifier = Modifier.fillMaxSize() + .background(AppTheme.craftoColors.background.screen) + .padding(horizontal = 16.dp) + .systemBarsPadding() + .verticalScroll(rememberScrollState()), + + ) { + if (state.loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).padding(top = 40.dp), + color = AppTheme.craftoColors.brand.primary + ) + } + } else { + Box( + modifier = Modifier.padding(top = 24.dp).align(Alignment.End), + ) { + SecondaryButton( + text = stringResource(Res.string.skip), + enabled = true, + onClick = { + val skipPage = pagerState.pageCount - 1 + coroutineScope.launch { + pagerState.animateScrollToPage( + page = skipPage, + animationSpec = spring( + dampingRatio = 0.85f, + stiffness = 44f + ) + ) + } + interactions::onSkipClick + }, + buttonState = ButtonState.Enable, + containerColor = AppTheme.craftoColors.button.secondary, + contentPadding = PaddingValues(vertical = 14.dp, horizontal = 24.dp) + ) + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.padding(vertical = 32.dp) + ) { page -> + OnboardingItem(state.onboardingData[page]) + } + + Spacer(modifier = Modifier.weight(1f)) + + OnboardingActionsRow( + pagerState = pagerState, + coroutineScope = coroutineScope, + state = state, + interactions = interactions, + buttonText = buttonText, + modifier = Modifier.padding(bottom = 24.dp) + ) + } + } +} + +@Composable +private fun OnboardingActionsRow( + modifier: Modifier, + pagerState: PagerState, + coroutineScope: CoroutineScope, + state: OnboardingScreenState, + interactions: OnboardingScreenInteractionListener, + buttonText: StringResource +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + OnboardingIndicator( + currentPage = pagerState.currentPage, + totalPage = pagerState.pageCount, + progressColor = AppTheme.craftoColors.brand.primary, + trackColor = AppTheme.craftoColors.shade.quaternary, + modifier = Modifier.width(100.dp) + ) + Spacer(modifier = Modifier.weight(1f)) + + PrimaryButton( + text = stringResource(buttonText), + enabled = true, + onClick = { + if (pagerState.currentPage < state.onboardingData.size - 1) { + val nextPage = pagerState.currentPage + 1 + coroutineScope.launch { + pagerState.animateScrollToPage( + page = nextPage, + animationSpec = spring( + dampingRatio = 0.85f, + stiffness = 440f + ) + ) + } + } else { + interactions::onGetStartedClick + } + }, + buttonState = ButtonState.Enable, + modifier = Modifier.animateContentSize( + animationSpec = spring( + dampingRatio = 0.85f, + stiffness = 44f, + ), + alignment = Alignment.BottomEnd + ), + contentPadding = PaddingValues(vertical = 14.dp, horizontal = 24.dp) + ) + } +} + + +@Preview +@Composable +private fun OnBoardingScreenPreview() { + AppTheme(isDarkTheme = false) { + OnboardingContent( + state = OnboardingScreenState(), + interactions = object : OnboardingScreenInteractionListener { + override fun onSkipClick() {} + + override fun onNextClick() {} + + override fun onGetStartedClick() {} + } + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenEffect.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenEffect.kt new file mode 100644 index 0000000..a3760f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenEffect.kt @@ -0,0 +1,7 @@ +package org.example.project.presentation.screens.onboarding + +interface OnboardingScreenEffect { + object NavigateToNext : OnboardingScreenEffect + object NavigateToGetStartedScreen : OnboardingScreenEffect + object NavigateToRegisterScreen : OnboardingScreenEffect +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenInteractionListener.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenInteractionListener.kt new file mode 100644 index 0000000..2b3a48d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenInteractionListener.kt @@ -0,0 +1,7 @@ +package org.example.project.presentation.screens.onboarding + +interface OnboardingScreenInteractionListener { + fun onSkipClick() + fun onNextClick() + fun onGetStartedClick() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenState.kt new file mode 100644 index 0000000..6a3a706 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/OnboardingScreenState.kt @@ -0,0 +1,10 @@ +package org.example.project.presentation.screens.onboarding + +import org.example.project.presentation.screens.onboarding.model.OnboardingUiState +import org.example.project.presentation.viewmodel.base.ErrorUiState + +data class OnboardingScreenState( + val onboardingData: List = emptyList(), + val loading: Boolean = false, + val errorMessage : ErrorUiState?= null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnBoardingIndicator.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnboardingIndicator.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnBoardingIndicator.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnboardingIndicator.kt index d2f192a..b3fdf26 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnBoardingIndicator.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnboardingIndicator.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp import org.example.project.presentation.designsystem.textstyle.AppTheme @Composable -fun OnBoardingIndicator( +fun OnboardingIndicator( currentPage: Int, totalPage: Int, modifier: Modifier = Modifier, diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnBoardingItem.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnboardingItem.kt similarity index 50% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnBoardingItem.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnboardingItem.kt index 89a7a2d..7a7d44f 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnBoardingItem.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/composable/OnboardingItem.kt @@ -1,9 +1,9 @@ package org.example.project.presentation.screens.onboarding.composable -import androidx.compose.foundation.Image 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -13,53 +13,51 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import org.example.project.presentation.designsystem.textstyle.AppTheme -import org.jetbrains.compose.resources.DrawableResource -import org.jetbrains.compose.resources.painterResource - -data class OnBoardingPage( - val imageRes: DrawableResource, - val title: String, - val description: String -) +import org.example.project.presentation.screens.onboarding.model.OnboardingUiState @Composable -fun OnBoardingItem( - page: OnBoardingPage, +fun OnboardingItem( + page: OnboardingUiState, modifier: Modifier = Modifier ) { Column( modifier = modifier.background(AppTheme.craftoColors.background.screen) ) { - Box( modifier = Modifier .background( shape = RoundedCornerShape(AppTheme.craftoRadius.x5l), color = Color.Transparent - ).padding(bottom = 32.dp).height(335.dp) - + ).height(335.dp) ) { - Image( - painter = painterResource(page.imageRes), + AsyncImage( + model = page.imageRes, contentDescription = "OnBoarding Image", - contentScale = ContentScale.FillBounds + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillBounds, + onError = { println("Image Load : ${it.result.throwable}") }, ) } - Text( - text = page.title, - style = AppTheme.textStyle.display, - color = AppTheme.craftoColors.shade.primary, - modifier = Modifier.padding(bottom = 16.dp) - ) + Column( + modifier = Modifier.padding(top = 32.dp).height(185.dp), + ) { + Text( + text = page.title, + style = AppTheme.textStyle.display, + color = AppTheme.craftoColors.shade.primary, + modifier = Modifier.padding(bottom = 16.dp, top = 19.dp), + ) - Text( - text = page.description, - style = AppTheme.textStyle.body.largeRegular, - color = AppTheme.craftoColors.shade.secondary, - modifier = Modifier.padding(bottom = 32.dp) - ) + Text( + text = page.description, + style = AppTheme.textStyle.body.largeRegular, + color = AppTheme.craftoColors.shade.secondary, + modifier = Modifier.padding(bottom = 19.dp) + ) + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/model/OnboardingUiState.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/model/OnboardingUiState.kt new file mode 100644 index 0000000..a725b93 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/model/OnboardingUiState.kt @@ -0,0 +1,16 @@ +package org.example.project.presentation.screens.onboarding.model + +import org.example.project.domain.entity.OnboardingItem + +data class OnboardingUiState( + val imageRes: String, + val title: String, + val description: String +) + +fun OnboardingItem.toUiState() : OnboardingUiState = + OnboardingUiState( + imageRes = imageRes, + title = title, + description = description + ) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/SplashScreen.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/splash/SplashScreen.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/SplashScreen.kt rename to composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/splash/SplashScreen.kt index b2bc47c..cb3a85b 100644 --- a/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/onboarding/SplashScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/screens/splash/SplashScreen.kt @@ -1,4 +1,4 @@ -package org.example.project.presentation.screens.onboarding +package org.example.project.presentation.screens.splash import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/composeApp/src/commonMain/kotlin/org/example/project/presentation/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/org/example/project/presentation/viewmodel/OnboardingViewModel.kt new file mode 100644 index 0000000..b6b3c6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/example/project/presentation/viewmodel/OnboardingViewModel.kt @@ -0,0 +1,62 @@ +package org.example.project.presentation.viewmodel + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import org.example.project.domain.entity.OnboardingItem +import org.example.project.domain.repository.OnboardingRepository +import org.example.project.presentation.screens.onboarding.OnboardingScreenEffect +import org.example.project.presentation.screens.onboarding.OnboardingScreenInteractionListener +import org.example.project.presentation.screens.onboarding.OnboardingScreenState +import org.example.project.presentation.screens.onboarding.model.toUiState +import org.example.project.presentation.viewmodel.base.BaseViewModel +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.Provided + + +@KoinViewModel +class OnboardingViewModel( + @Provided private val repository: OnboardingRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) : + BaseViewModel(initialState = OnboardingScreenState()), + OnboardingScreenInteractionListener { + + init { + loadData() + } + + + private fun loadData() { + tryToCall( + call = { + updateState { it.copy(loading = true) } + repository.getOnboardingData() + }, + onSuccess = ::onLoadDataSuccess , + onError = { errorState -> updateState { it.copy(errorMessage = errorState) } }, + dispatcher = ioDispatcher + ) + } + + private fun onLoadDataSuccess(data: List) { + updateState { + it.copy( + onboardingData = data.map { item -> item.toUiState() }, + loading = false + ) + } + } + + override fun onSkipClick() { + sendNewEffect(OnboardingScreenEffect.NavigateToGetStartedScreen) + } + + override fun onNextClick() { + sendNewEffect(OnboardingScreenEffect.NavigateToNext) + } + + override fun onGetStartedClick() { + sendNewEffect(OnboardingScreenEffect.NavigateToRegisterScreen) + } +} \ 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 new file mode 100644 index 0000000..fed8979 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/example/project/di/getHttpEngine.ios.kt @@ -0,0 +1,6 @@ +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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97d4998..46e4f10 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,13 +10,17 @@ androidx-core = "1.17.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.9.3" androidx-testExt = "1.3.0" +coilComposeVersion = "3.3.0" composeMultiplatform = "1.8.2" junit = "4.13.2" kotlin = "2.2.20" koin = "4.1.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" @@ -24,12 +28,14 @@ google-services = "4.4.3" #newer version of firebase-bom causes gradle errors firebase-bom = "33.16.0" firebaseCrashlytics = "3.0.6" +ktorClientCio = "3.3.0" [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" } 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" } @@ -51,6 +57,27 @@ koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = " koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +# ktor +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-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" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } + + + +# Coil +coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } +coil-compose-core = { module = "io.coil-kt.coil3:coil-compose-core", version.ref = "coil" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } + + + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } @@ -59,4 +86,14 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlinx-serialization = {id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + + +[bundles] +coil = [ + "coil", + "coil-compose-core", + "coil-compose", + "coil-network-ktor3", +] \ No newline at end of file