From a3b8a15725d592e1b9acc4cbe6e66c2b269fb42d Mon Sep 17 00:00:00 2001 From: stslex Date: Sun, 17 Dec 2023 16:22:34 +0300 Subject: [PATCH] init core auth --- .../src/commonMain/kotlin/InitialApp.kt | 27 +++++- .../kotlin/navigator/AppNavigatorImpl.kt | 9 ++ .../stslex/core/database/store/UserStore.kt | 4 +- .../core/database/store/UserStoreImpl.kt | 5 +- core/network/build.gradle.kts | 1 + .../network/api/base/BaseNetworkClient.kt | 4 +- .../api/base/DefaultNetworkClientImpl.kt | 7 -- .../core/network/api/server/ServerApi.kt | 21 ----- .../network/api/server/ServerApiClient.kt | 94 +++++++++++++++++++ .../network/api/server/TokenResponseModel.kt | 16 ++++ .../network/clients/auth/client/AuthClient.kt | 11 +++ .../clients/auth/client/AuthClientImpl.kt | 53 +++++++++++ .../clients/auth/request/AuthRequest.kt | 12 +++ .../clients/auth/response/LoginOkResponse.kt | 16 ++++ .../clients/auth/response/RegisterRequest.kt | 14 +++ .../stslex/core/network/di/NetworkModule.kt | 36 +++++-- .../network/utils/token/AuthController.kt | 13 +++ .../network/utils/token/AuthControllerImpl.kt | 31 ++++++ .../core/network/utils/token/TokenModel.kt | 25 +++++ .../com/stslex/core/ui/mvi/BaseStore.kt | 6 +- .../stslex/core/ui/navigation/AppNavigator.kt | 2 + .../feature/auth/data/AuthRepository.kt | 5 +- .../feature/auth/data/AuthRepositoryImpl.kt | 14 ++- .../com/stslex/feature/auth/di/AuthModule.kt | 2 +- .../feature/auth/domain/AuthInteractor.kt | 4 +- .../feature/auth/domain/AuthInteractorImpl.kt | 15 ++- .../auth/ui/components/AuthFieldsColumn.kt | 44 ++++++++- .../auth/ui/model/screen/AuthScreenState.kt | 17 +++- .../screen/text_field/LoginTextFieldState.kt | 32 +++++++ .../screen/text_field/base/AuthTextField.kt | 2 +- .../stslex/feature/auth/ui/store/AuthStore.kt | 12 ++- .../auth/ui/store/AuthStoreComponent.kt | 6 ++ .../com/stslex/feature/film/ui/FilmScreen.kt | 2 +- gradle/libs.versions.toml | 12 ++- 34 files changed, 509 insertions(+), 65 deletions(-) delete mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/DefaultNetworkClientImpl.kt delete mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApi.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApiClient.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/TokenResponseModel.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClient.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClientImpl.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/request/AuthRequest.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/LoginOkResponse.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/RegisterRequest.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthController.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthControllerImpl.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/TokenModel.kt create mode 100644 feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/LoginTextFieldState.kt diff --git a/composeApp/src/commonMain/kotlin/InitialApp.kt b/composeApp/src/commonMain/kotlin/InitialApp.kt index 7c338e85..7a2c1ac3 100644 --- a/composeApp/src/commonMain/kotlin/InitialApp.kt +++ b/composeApp/src/commonMain/kotlin/InitialApp.kt @@ -5,10 +5,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.SlideTransition -import com.stslex.core.database.store.UserStore +import com.stslex.core.network.utils.token.AuthController +import com.stslex.core.ui.navigation.AppNavigator +import com.stslex.core.ui.navigation.AppScreen import com.stslex.feature.auth.ui.AuthScreen import main_screen.MainScreen import org.koin.compose.getKoin @@ -17,7 +21,26 @@ import org.koin.compose.getKoin fun InitialApp( modifier: Modifier = Modifier ) { - val userStore = getKoin().get() + val koin = getKoin() + val userStore = remember { + koin.get() + } + val navigator = remember { + koin.get() + } + + LaunchedEffect(Unit) { + userStore.isAuthFlow.collect { isAuth -> + val currentScreen = navigator.currentScreen + if ( + isAuth.not() && + currentScreen != null && + currentScreen != AppScreen.Auth + ) { + navigator.navigate(AppScreen.Auth) + } + } + } Scaffold( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/navigator/AppNavigatorImpl.kt b/composeApp/src/commonMain/kotlin/navigator/AppNavigatorImpl.kt index 48eeafce..8a9e3dd0 100644 --- a/composeApp/src/commonMain/kotlin/navigator/AppNavigatorImpl.kt +++ b/composeApp/src/commonMain/kotlin/navigator/AppNavigatorImpl.kt @@ -18,6 +18,15 @@ class AppNavigatorImpl : AppNavigator { _navigator = navigator } + override val currentScreen: AppScreen? + get() = when (val item = _navigator?.lastItemOrNull) { + AuthScreen -> AppScreen.Auth + MainScreen -> AppScreen.Main + MatchFeedScreen -> AppScreen.MatchFeed + is FilmScreen -> AppScreen.Film(item.id) + else -> null + } + override fun navigate( screen: AppScreen ) { diff --git a/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStore.kt b/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStore.kt index 2fd0eee2..cef7d36c 100644 --- a/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStore.kt +++ b/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStore.kt @@ -2,10 +2,10 @@ package com.stslex.core.database.store interface UserStore { - var userToken: String + var accessToken: String var refreshToken: String var username: String var uuid: String - val isAuth: Boolean + fun clear() } diff --git a/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStoreImpl.kt b/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStoreImpl.kt index 5c7e87cf..6c981c19 100644 --- a/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStoreImpl.kt +++ b/core/database/src/commonMain/kotlin/com/stslex/core/database/store/UserStoreImpl.kt @@ -6,7 +6,7 @@ class UserStoreImpl( private val userSettings: UserSettings ) : UserStore { - override var userToken: String + override var accessToken: String get() = userSettings.getString(KEY_TOKEN, EMPTY_VALUE) set(value) { userSettings[KEY_TOKEN] = value @@ -30,9 +30,6 @@ class UserStoreImpl( userSettings[KEY_UUID] = value } - override val isAuth: Boolean - get() = userToken.isBlank().not() - override fun clear() { userSettings.clear() } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index f8bc50b3..bde0fe70 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(project(":core:core")) + implementation(project(":core:database")) implementation(libs.bundles.ktor) implementation(libs.kotlinx.serialization.json) implementation(libs.slf4j.simple) diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/BaseNetworkClient.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/BaseNetworkClient.kt index 1d5eea25..b3fc06d7 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/BaseNetworkClient.kt +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/BaseNetworkClient.kt @@ -1,16 +1,16 @@ package com.stslex.core.network.api.base import com.stslex.core.core.AppDispatcher -import com.stslex.core.network.api.base.model.DefaultRequest import com.stslex.core.network.api.base.NetworkClientBuilder.setupDefaultRequest import com.stslex.core.network.api.base.NetworkClientBuilder.setupLogging import com.stslex.core.network.api.base.NetworkClientBuilder.setupNegotiation +import com.stslex.core.network.api.base.model.DefaultRequest import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.cache.HttpCache import kotlinx.coroutines.withContext -abstract class BaseNetworkClient( +open class BaseNetworkClient( private val appDispatcher: AppDispatcher, defaultRequest: DefaultRequest = DefaultRequest.EMPTY ) : NetworkClient { diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/DefaultNetworkClientImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/DefaultNetworkClientImpl.kt deleted file mode 100644 index 78db785b..00000000 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/base/DefaultNetworkClientImpl.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.stslex.core.network.api.base - -import com.stslex.core.core.AppDispatcher - -class DefaultNetworkClientImpl( - dispatcher: AppDispatcher -) : BaseNetworkClient(dispatcher) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApi.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApi.kt deleted file mode 100644 index 09b4b81a..00000000 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApi.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.stslex.core.network.api.server - -import com.stslex.core.core.AppDispatcher -import com.stslex.core.network.api.base.BaseNetworkClient -import com.stslex.core.network.api.base.model.DefaultRequest - -interface ServerApi - -// TODO add auth, reconnect, error handling -class ServerApiImpl( - dispatcher: AppDispatcher -) : ServerApi, BaseNetworkClient( - appDispatcher = dispatcher, - defaultRequest = DefaultRequest( - hostUrl = "https://api.stslex.com", - headers = mapOf( - "Content-Type" to "application/json", - "Accept" to "application/json" - ) - ) -) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApiClient.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApiClient.kt new file mode 100644 index 00000000..fe3ebf4f --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/ServerApiClient.kt @@ -0,0 +1,94 @@ +package com.stslex.core.network.api.server + +import com.stslex.core.core.AppDispatcher +import com.stslex.core.network.api.base.NetworkClient +import com.stslex.core.network.api.base.NetworkClientBuilder.setupLogging +import com.stslex.core.network.api.base.NetworkClientBuilder.setupNegotiation +import com.stslex.core.network.utils.token.AuthController +import com.stslex.core.network.utils.token.toModel +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.ResponseException +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequest +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLProtocol +import io.ktor.http.contentType +import io.ktor.http.encodedPath +import kotlinx.coroutines.withContext + +interface ServerApiClient : NetworkClient + +class ServerApiClientImpl( + private val tokenProvider: AuthController, + private val appDispatcher: AppDispatcher +) : ServerApiClient { + + private val client = HttpClient(CIO) { + setupNegotiation() + + setupLogging() + expectSuccess = true + + install(Auth) { + bearer { + loadTokens { + BearerTokens( + accessToken = tokenProvider.accessToken, + refreshToken = tokenProvider.refreshToken + ) + } + } + } + HttpResponseValidator { + handleResponseExceptionWithRequest(errorParser) + } + defaultRequest { + url(API_HOST) { + host = API_HOST + encodedPath = "api/v1" + protocol = URLProtocol.HTTP + contentType(ContentType.Application.Json) + } + headers { + append(API_KEY_NAME, "API_KEY") // TODO + } + } + } + + override suspend fun request( + request: suspend HttpClient.() -> T + ): T = withContext(appDispatcher.io) { + request(client) + } + + private val errorParser: suspend (Throwable, HttpRequest) -> Unit + get() = { exception, _ -> + val clientException = exception as? ResponseException ?: throw exception + if (HttpStatusCode.Unauthorized.value == clientException.response.status.value) { + refreshToken() + } else { + throw clientException + } + } + + private suspend fun refreshToken() { + val tokenResponse = client + .get("passport/refresh") + .body() + tokenProvider.update(tokenResponse.toModel()) + } + + companion object { + private const val API_KEY_NAME = "API_KEY" + private const val API_HOST = "api_host" + } +} diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/TokenResponseModel.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/TokenResponseModel.kt new file mode 100644 index 00000000..09a60825 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/api/server/TokenResponseModel.kt @@ -0,0 +1,16 @@ +package com.stslex.core.network.api.server + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TokenResponseModel( + @SerialName("uuid") + val uuid: String, + @SerialName("username") + val username: String, + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String +) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClient.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClient.kt new file mode 100644 index 00000000..c64e446c --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClient.kt @@ -0,0 +1,11 @@ +package com.stslex.core.network.clients.auth.client + +import com.stslex.core.network.clients.auth.response.LoginOkResponse + +interface AuthClient { + + suspend fun authUser(login: String, password: String): LoginOkResponse + + suspend fun registerUser(login: String, username: String, password: String): LoginOkResponse +} + diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClientImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClientImpl.kt new file mode 100644 index 00000000..6c74444d --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/client/AuthClientImpl.kt @@ -0,0 +1,53 @@ +package com.stslex.core.network.clients.auth.client + +import com.stslex.core.network.api.server.ServerApiClient +import com.stslex.core.network.clients.auth.request.AuthRequest +import com.stslex.core.network.clients.auth.response.LoginOkResponse +import com.stslex.core.network.clients.auth.response.RegisterRequest +import com.stslex.core.network.utils.token.AuthController +import com.stslex.core.network.utils.token.toModel +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.util.InternalAPI + +class AuthClientImpl( + private val networkClient: ServerApiClient, + private val tokenController: AuthController +) : AuthClient { + + @OptIn(InternalAPI::class) + override suspend fun authUser( + login: String, + password: String + ): LoginOkResponse = networkClient.request { + post("$AUTH_URL/login") { + body = AuthRequest( + login = login, + password = password + ) + }.body().also { + tokenController.update(it.toModel()) + } + } + + @OptIn(InternalAPI::class) + override suspend fun registerUser( + login: String, + username: String, + password: String + ): LoginOkResponse = networkClient.request { + post("$AUTH_URL/register") { + body = RegisterRequest( + login = login, + password = password, + username = username + ) + }.body().also { + tokenController.update(it.toModel()) + } + } + + companion object { + private const val AUTH_URL = "passport" + } +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/request/AuthRequest.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/request/AuthRequest.kt new file mode 100644 index 00000000..63cf7f84 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/request/AuthRequest.kt @@ -0,0 +1,12 @@ +package com.stslex.core.network.clients.auth.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthRequest( + @SerialName("login") + val login: String, + @SerialName("password") + val password: String +) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/LoginOkResponse.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/LoginOkResponse.kt new file mode 100644 index 00000000..c6e5a9f6 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/LoginOkResponse.kt @@ -0,0 +1,16 @@ +package com.stslex.core.network.clients.auth.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginOkResponse( + @SerialName("uuid") + val uuid: String, + @SerialName("username") + val username: String, + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String +) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/RegisterRequest.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/RegisterRequest.kt new file mode 100644 index 00000000..7206ad5e --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/clients/auth/response/RegisterRequest.kt @@ -0,0 +1,14 @@ +package com.stslex.core.network.clients.auth.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterRequest( + @SerialName("login") + val login: String, + @SerialName("username") + val username: String, + @SerialName("password") + val password: String +) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt index 11a20bf9..fd363ed7 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/di/NetworkModule.kt @@ -1,24 +1,41 @@ package com.stslex.core.network.di -import com.stslex.core.network.api.base.DefaultNetworkClientImpl -import com.stslex.core.network.api.base.NetworkClient import com.stslex.core.network.api.kinopoisk.api.KinopoiskApiClient import com.stslex.core.network.api.kinopoisk.api.KinopoiskApiClientImpl import com.stslex.core.network.api.kinopoisk.source.KinopoiskNetworkClient import com.stslex.core.network.api.kinopoisk.source.KinopoiskNetworkClientImpl -import com.stslex.core.network.api.server.ServerApi -import com.stslex.core.network.api.server.ServerApiImpl +import com.stslex.core.network.api.server.ServerApiClient +import com.stslex.core.network.api.server.ServerApiClientImpl +import com.stslex.core.network.clients.auth.client.AuthClient +import com.stslex.core.network.clients.auth.client.AuthClientImpl import com.stslex.core.network.clients.film.client.FilmClient import com.stslex.core.network.clients.film.client.MockFilmClientImpl import com.stslex.core.network.clients.profile.client.MockProfileClientImpl import com.stslex.core.network.clients.profile.client.ProfileClient +import com.stslex.core.network.utils.token.AuthController +import com.stslex.core.network.utils.token.AuthControllerImpl import org.koin.dsl.module val coreNetworkModule = module { - single { DefaultNetworkClientImpl(dispatcher = get()) } - single { ServerApiImpl(dispatcher = get()) } + /*Kinopoisk Api*/ single { KinopoiskApiClientImpl(appDispatcher = get()) } single { KinopoiskNetworkClientImpl(apiClient = get()) } + + /*Server Api*/ + single { + ServerApiClientImpl( + appDispatcher = get(), + tokenProvider = get() + ) + } + + /*Clients*/ + single { + AuthClientImpl( + networkClient = get(), + tokenController = get() + ) + } single { MockFilmClientImpl() // FilmClientImpl( @@ -27,4 +44,11 @@ val coreNetworkModule = module { // ) } single { MockProfileClientImpl() } + + /*Utils*/ + single { + AuthControllerImpl( + userStore = get(), + ) + } } \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthController.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthController.kt new file mode 100644 index 00000000..c1567de0 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthController.kt @@ -0,0 +1,13 @@ +package com.stslex.core.network.utils.token + +import kotlinx.coroutines.flow.StateFlow + +interface AuthController { + val isAuth: Boolean + val isAuthFlow: StateFlow + val accessToken: String + val refreshToken: String + + fun update(token: TokenModel) +} + diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthControllerImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthControllerImpl.kt new file mode 100644 index 00000000..384d1bb3 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/AuthControllerImpl.kt @@ -0,0 +1,31 @@ +package com.stslex.core.network.utils.token + +import com.stslex.core.database.store.UserStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AuthControllerImpl( + private val userStore: UserStore +) : AuthController { + + override val isAuth: Boolean + get() = userStore.accessToken.isNotEmpty() + + private val _isAuthFlow: MutableStateFlow = MutableStateFlow(isAuth) + override val isAuthFlow: StateFlow = _isAuthFlow.asStateFlow() + + override val accessToken: String + get() = userStore.accessToken + + override val refreshToken: String + get() = userStore.refreshToken + + override fun update(token: TokenModel) { + userStore.accessToken = token.accessToken + userStore.refreshToken = token.refreshToken + userStore.username = token.username + userStore.uuid = token.uuid + _isAuthFlow.value = token.accessToken.isNotEmpty() + } +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/TokenModel.kt b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/TokenModel.kt new file mode 100644 index 00000000..f04bd8fe --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/core/network/utils/token/TokenModel.kt @@ -0,0 +1,25 @@ +package com.stslex.core.network.utils.token + +import com.stslex.core.network.api.server.TokenResponseModel +import com.stslex.core.network.clients.auth.response.LoginOkResponse + +data class TokenModel( + val uuid: String, + val username: String, + val accessToken: String, + val refreshToken: String +) + +fun TokenResponseModel.toModel() = TokenModel( + uuid = uuid, + username = username, + accessToken = accessToken, + refreshToken = refreshToken +) + +fun LoginOkResponse.toModel() = TokenModel( + uuid = uuid, + username = username, + accessToken = accessToken, + refreshToken = refreshToken +) diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt index d9739b8b..cfafdcf9 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt @@ -30,11 +30,9 @@ abstract class BaseStore( private fun exceptionHandler( onError: suspend (cause: Throwable) -> Unit = {}, - ) = CoroutineExceptionHandler { coroutineContext, throwable -> + ) = CoroutineExceptionHandler { _, throwable -> Logger.exception(throwable) - CoroutineScope( - context = coroutineContext + appDispatcher.default - ).launch(coroutineExceptionHandler) { + screenModelScope.launch(appDispatcher.default + coroutineExceptionHandler) { onError(throwable) } } diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/navigation/AppNavigator.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/navigation/AppNavigator.kt index fadaf30a..58f6b6e7 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/navigation/AppNavigator.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/navigation/AppNavigator.kt @@ -4,6 +4,8 @@ import cafe.adriel.voyager.navigator.Navigator interface AppNavigator { + val currentScreen: AppScreen? + fun navigate(screen: AppScreen) fun setNavigator(navigator: Navigator) diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepository.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepository.kt index 066f24d9..b27f2457 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepository.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepository.kt @@ -1,5 +1,8 @@ package com.stslex.feature.auth.data interface AuthRepository { -} + suspend fun auth(login: String, password: String) + + suspend fun register(login: String, username: String, password: String) +} diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepositoryImpl.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepositoryImpl.kt index 629b5d16..367aa46e 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepositoryImpl.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/data/AuthRepositoryImpl.kt @@ -1,4 +1,16 @@ package com.stslex.feature.auth.data -class AuthRepositoryImpl : AuthRepository { +import com.stslex.core.network.clients.auth.client.AuthClient + +class AuthRepositoryImpl( + private val client: AuthClient +) : AuthRepository { + + override suspend fun auth(login: String, password: String) { + client.authUser(login, password) + } + + override suspend fun register(login: String, username: String, password: String) { + client.registerUser(login, username, password) + } } \ No newline at end of file diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/di/AuthModule.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/di/AuthModule.kt index 3846ee3e..d35a89ac 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/di/AuthModule.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/di/AuthModule.kt @@ -10,7 +10,7 @@ import com.stslex.feature.auth.ui.store.AuthStore import org.koin.dsl.module val featureAuthModule = module { - factory { AuthRepositoryImpl() } + factory { AuthRepositoryImpl(client = get()) } factory { AuthInteractorImpl(authRepository = get()) } factory { AuthRouterImpl(navigator = get()) } factory { diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractor.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractor.kt index da1ec90d..d8dcf059 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractor.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractor.kt @@ -2,7 +2,7 @@ package com.stslex.feature.auth.domain interface AuthInteractor { - suspend fun register(username: String, password: String) + suspend fun auth(login: String, password: String) - suspend fun auth(username: String, password: String) + suspend fun register(login: String, username: String, password: String) } diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractorImpl.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractorImpl.kt index a1682889..40c28d1e 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractorImpl.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/domain/AuthInteractorImpl.kt @@ -6,11 +6,18 @@ class AuthInteractorImpl( private val authRepository: AuthRepository ) : AuthInteractor { - override suspend fun auth(username: String, password: String) { - TODO("Not yet implemented") + override suspend fun auth(login: String, password: String) { + authRepository.auth( + login = login, + password = password + ) } - override suspend fun register(username: String, password: String) { - TODO("Not yet implemented") + override suspend fun register(login: String, username: String, password: String) { + authRepository.register( + login = login, + username = username, + password = password + ) } } \ No newline at end of file diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/components/AuthFieldsColumn.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/components/AuthFieldsColumn.kt index bdbf3cee..dcacf56f 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/components/AuthFieldsColumn.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/components/AuthFieldsColumn.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import com.stslex.core.ui.theme.AppDimension import com.stslex.feature.auth.ui.model.screen.AuthScreenState +import com.stslex.feature.auth.ui.model.screen.text_field.LoginTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.UsernameTextFieldState @Composable @@ -35,7 +36,19 @@ internal fun AuthFieldsColumn( ), horizontalAlignment = Alignment.CenterHorizontally ) { - AuthUsernameTextField(state.usernameState) + AuthLoginTextField(state.loginState) + AnimatedVisibility( + visible = state.isRegisterState, + enter = fadeIn(tween(300)) + expandVertically(tween(600)), + exit = fadeOut(tween(300)) + shrinkVertically(tween(600)) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(AppDimension.Padding.small)) + AuthUsernameTextField(state.usernameState) + } + } Spacer(Modifier.height(AppDimension.Padding.medium)) AuthPasswordTextField(state.passwordEnterState) AnimatedVisibility( @@ -86,4 +99,33 @@ private fun AuthUsernameTextField( } }, ) +} + +@Composable +private fun AuthLoginTextField( + state: LoginTextFieldState, + modifier: Modifier = Modifier +) { + OutlinedTextField( + modifier = modifier + .fillMaxWidth(), + value = state.text, + onValueChange = state::onTextChange, + singleLine = true, + label = { + Text(text = "login") + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + supportingText = { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.align(Alignment.CenterEnd), + text = state.supportingEndText + ) + } + }, + ) } \ No newline at end of file diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt index a72edc8a..974b35cc 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/AuthScreenState.kt @@ -15,9 +15,11 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController +import com.stslex.feature.auth.ui.model.screen.text_field.LoginTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.PasswordInputTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.PasswordSubmitTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.UsernameTextFieldState +import com.stslex.feature.auth.ui.model.screen.text_field.rememberLoginTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.rememberPasswordInputTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.rememberPasswordSubmitTextFieldState import com.stslex.feature.auth.ui.model.screen.text_field.rememberUsernameTextFieldState @@ -30,6 +32,7 @@ import com.stslex.feature.auth.ui.store.AuthStoreComponent.State @Stable data class AuthScreenState @OptIn(ExperimentalMaterialApi::class) constructor( val screenLoadingState: ScreenLoadingState = ScreenLoadingState.Content, + val loginState: LoginTextFieldState, val usernameState: UsernameTextFieldState, val passwordEnterState: PasswordInputTextFieldState, val passwordSubmitState: PasswordSubmitTextFieldState, @@ -42,10 +45,12 @@ data class AuthScreenState @OptIn(ExperimentalMaterialApi::class) constructor( val isFieldsValid: Boolean get() { - val isCorrectLength = usernameState.text.length >= 4 && - passwordEnterState.text.length >= 4 + val isCorrectLength = loginState.text.length >= 6 && + passwordEnterState.text.length >= 8 val isEqualsPasswords = passwordEnterState.text == passwordSubmitState.text - val isRegisterPassword = authFieldsState == AuthFieldsState.AUTH || isEqualsPasswords + val isUsernameValid = usernameState.text.length >= 6 + val isRegisterPassword = authFieldsState == AuthFieldsState.AUTH + || (isEqualsPasswords && isUsernameValid) return isCorrectLength && isRegisterPassword } @@ -72,6 +77,11 @@ fun rememberAuthScreenState( processAction = processAction ) + val loginTextFieldState = rememberLoginTextFieldState( + text = screenState.login, + processAction = processAction + ) + val passwordEnterState = rememberPasswordInputTextFieldState( processAction = processAction, text = screenState.password, @@ -100,6 +110,7 @@ fun rememberAuthScreenState( return remember(screenState) { AuthScreenState( screenLoadingState = screenState.screenLoadingState, + loginState = loginTextFieldState, passwordEnterState = passwordEnterState, passwordSubmitState = passwordSubmitState, authFieldsState = screenState.authFieldsState, diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/LoginTextFieldState.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/LoginTextFieldState.kt new file mode 100644 index 00000000..317a8a5d --- /dev/null +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/LoginTextFieldState.kt @@ -0,0 +1,32 @@ +package com.stslex.feature.auth.ui.model.screen.text_field + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import com.stslex.feature.auth.ui.model.screen.text_field.base.AuthTextField +import com.stslex.feature.auth.ui.store.AuthStoreComponent.Action.InputAction + +@Stable +data class LoginTextFieldState( + private val processAction: (InputAction.LoginInput) -> Unit, + override val text: String, +) : AuthTextField() { + + override val sendAction: (text: String) -> Unit + get() = { value -> + processAction(InputAction.LoginInput(value)) + } + + override val label: String = "login" +} + +@Composable +fun rememberLoginTextFieldState( + text: String, + processAction: (InputAction.LoginInput) -> Unit +): LoginTextFieldState = remember(text) { + LoginTextFieldState( + text = text, + processAction = processAction + ) +} diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt index 2cd8cd56..9f0e58e1 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/model/screen/text_field/base/AuthTextField.kt @@ -19,6 +19,6 @@ abstract class AuthTextField { } companion object { - private const val MAX_SYMBOL_COUNT = 10 + private const val MAX_SYMBOL_COUNT = 16 } } diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt index f1937b92..eb4650dd 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStore.kt @@ -25,12 +25,21 @@ class AuthStore( when (action) { is Action.InputAction.PasswordInput -> processPasswordInput(action) is Action.InputAction.PasswordSubmitInput -> processPasswordSubmitInput(action) + is Action.InputAction.LoginInput -> processLoginInput(action) is Action.InputAction.UsernameInput -> processUsernameInput(action) is Action.OnAuthFieldChange -> processAuthFieldChange(action) is Action.OnSubmitClicked -> processSubmitClicked(action) } } + private fun processLoginInput(action: Action.InputAction.LoginInput) { + updateState { currentValue -> + currentValue.copy( + login = action.value + ) + } + } + private fun processUsernameInput(action: Action.InputAction.UsernameInput) { updateState { currentValue -> currentValue.copy( @@ -90,6 +99,7 @@ class AuthStore( }) { interactor .register( + login = state.login, username = state.username, password = state.password ) @@ -115,7 +125,7 @@ class AuthStore( }) { interactor .auth( - username = state.username, + login = state.login, password = state.password ) } diff --git a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt index 3042799f..2a428759 100644 --- a/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt +++ b/feature/auth/src/commonMain/kotlin/com/stslex/feature/auth/ui/store/AuthStoreComponent.kt @@ -6,6 +6,7 @@ interface AuthStoreComponent : Store { data class State( val screenLoadingState: ScreenLoadingState, + val login: String, val username: String, val password: String, val passwordSubmit: String, @@ -14,6 +15,7 @@ interface AuthStoreComponent : Store { companion object { val INITIAL = State( screenLoadingState = ScreenLoadingState.Content, + login = "", username = "", password = "", passwordSubmit = "", @@ -37,6 +39,10 @@ interface AuthStoreComponent : Store { open val value: String ) : Action { + data class LoginInput( + override val value: String + ) : InputAction(value) + data class UsernameInput( override val value: String ) : InputAction(value) diff --git a/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/FilmScreen.kt b/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/FilmScreen.kt index 646ecda1..7c5abbc7 100644 --- a/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/FilmScreen.kt +++ b/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/FilmScreen.kt @@ -22,7 +22,7 @@ import com.stslex.feature.film.ui.store.FilmStoreComponent.Action import com.stslex.feature.film.ui.store.FilmStoreComponent.State data class FilmScreen( - private val id: String + val id: String ) : Screen { @Composable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c23105c..9c89079b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,8 @@ kamel = "0.9.0" coil = "2.5.0" buildConfig = "4.2.0" +lifecycleRuntimeKtx = "2.6.2" +composeBom = "2023.08.00" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } @@ -71,6 +73,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.6.0" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version = "1.7.9" } @@ -79,6 +82,12 @@ coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", ve coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } [plugins] kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -103,5 +112,6 @@ ktor = [ "ktor-serialization-kotlinx-json", "ktor-client-cio", "ktor-client-logging", - "slf4j-simple" + "ktor-client-auth", + "slf4j-simple", ] \ No newline at end of file