From eacb784bfb900cee0c3571d8595d42edd19de701 Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Thu, 14 May 2026 23:10:45 +0700 Subject: [PATCH] Add Napier logger library --- build.gradle.kts | 1 + composeApp/build.gradle.kts | 18 +++-- .../src/androidMain/AndroidManifest.xml | 5 +- .../app/torquevaultkmp/AppStorage.android.kt | 33 ++++++++ .../app/torquevaultkmp/MainActivity.kt | 3 +- .../app/torquevaultkmp/MyApplication.kt | 17 +++++ .../torquevaultkmp/NetworkEngine.android.kt | 75 ++++++++++++++++++ .../app/torquevaultkmp/di/PlatformModules.kt | 10 +++ .../app/torquevaultkmp/App.kt | 17 +++-- .../app/torquevaultkmp/AppViewModel.kt | 27 +++++++ .../app/torquevaultkmp/NetworkEngine.kt | 7 ++ .../app/torquevaultkmp/components/AppBar.kt | 6 -- .../data/network/NetworkModule.kt | 25 ++++++ .../data/network/api/AuthApi.kt | 50 ++++++++++++ .../data/network/api/Endpoints.kt | 8 ++ .../data/network/api/WorkOrderApi.kt | 27 +++++++ .../data/network/dto/AuthDto.kt | 12 +++ .../data/network/dto/WorkOrderDto.kt | 34 +++++++++ .../data/network/mapper/AuthMapper.kt | 24 ++++++ .../data/network/mapper/WorkOrderMapper.kt | 35 +++++++++ .../data/repository/WorkOrderRepository.kt | 8 ++ .../repository/WorkOrderRepositoryImpl.kt | 15 ++++ .../torquevaultkmp/data/storage/AppStorage.kt | 13 ++++ .../torquevaultkmp/data/storage/ReferKeys.kt | 7 ++ .../data/storage/TokenManager.kt | 29 +++++++ .../app/torquevaultkmp/di/Koin.kt | 14 +++- .../app/torquevaultkmp/di/Modules.kt | 43 ++++++++++- .../app/torquevaultkmp/domain/model/Auth.kt | 6 ++ .../torquevaultkmp/domain/model/WorkOrder.kt | 44 +++++++++++ .../domain/usecase/FetchWorkOrdersUseCase.kt | 10 +++ .../app/torquevaultkmp/screens/MainScreen.kt | 4 +- .../screens/auth/AuthViewModel.kt | 43 +++++++++++ .../screens/auth/LoginScreen.kt | 57 +++++++------- .../torquevaultkmp/screens/home/HomeScreen.kt | 46 ++++++++--- .../screens/home/HomeViewModel.kt | 33 ++++++++ .../app/torquevaultkmp/utils/ApiException.kt | 11 +++ .../app/torquevaultkmp/utils/NoOpAntilog.kt | 9 +++ .../app/torquevaultkmp/AppStorage.ios.kt | 31 ++++++++ .../app/torquevaultkmp/MainViewController.kt | 11 ++- .../app/torquevaultkmp/NetworkEngine.ios.kt | 76 +++++++++++++++++++ .../app/torquevaultkmp/di/PlatformModules.kt | 9 +++ gradle/libs.versions.toml | 27 +++++-- iosApp/iosApp/Info.plist | 14 ++++ 43 files changed, 922 insertions(+), 72 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MyApplication.kt create mode 100644 composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/NetworkModule.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/AuthDto.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/AuthMapper.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/ReferKeys.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/Auth.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ApiException.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/NoOpAntilog.kt create mode 100644 composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt diff --git a/build.gradle.kts b/build.gradle.kts index cf780d5..d63f5ec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9d616f4..dd22698 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -34,6 +35,7 @@ kotlin { implementation(libs.koin.androidx.compose) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) + implementation(libs.ktor.client.okhttp) } commonMain.dependencies { implementation(libs.compose.runtime) @@ -45,19 +47,19 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation(project.dependencies.platform(libs.koin.bom)) - implementation(libs.koin.core) - implementation(libs.koin.compose) - implementation(libs.koin.compose.viewmodel) - implementation(libs.compose.material.icons.extended) implementation(libs.androidx.navigation.compose) -// implementation(libs.compose.material3.adaptiveNavigation3) -// implementation(libs.androidx.lifecycle.viewmodelNavigation3) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.bundles.ktor) + + implementation(libs.koin.core) + implementation(libs.koin.compose.viewmodel) + + implementation(libs.logger.napier) } iosMain.dependencies { - + implementation(libs.ktor.client.darwin) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 26403a7..cccba71 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,7 +1,10 @@ - + + save(key: String, value: T) { + when (value) { + is String -> prefs.edit { putString(key, value) } + is Int -> prefs.edit { putInt(key, value) } + is Boolean -> prefs.edit { putBoolean(key, value) } + else -> throw IllegalArgumentException("Unsupported type") + } + } + + @Suppress("UNCHECKED_CAST") + override fun load(key: String, type: KClass): T? { + return when (type) { + String::class -> prefs.getString(key, null) as T? + Int::class -> prefs.getInt(key, -1) as T? + Boolean::class -> prefs.getBoolean(key, false) as T? + else -> throw IllegalArgumentException("Unsupported type") + } + } + + override fun remove(key: String) { + prefs.edit { remove(key) } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainActivity.kt index 3367a93..dc44c89 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainActivity.kt @@ -6,12 +6,13 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import com.digitoolsolutions.app.torquevaultkmp.di.initKoin +import org.koin.android.ext.koin.androidContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - setContent { App() } diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MyApplication.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MyApplication.kt new file mode 100644 index 0000000..67eaa6b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MyApplication.kt @@ -0,0 +1,17 @@ +package com.digitoolsolutions.app.torquevaultkmp + +import android.app.Application +import com.digitoolsolutions.app.torquevaultkmp.di.initKoin +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import org.koin.android.ext.koin.androidContext +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + // Add logger lib + Napier.base(DebugAntilog()) // NoOpAntilog() for release + initKoin { + androidContext(this@MyApplication) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt new file mode 100644 index 0000000..7851ff0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.android.kt @@ -0,0 +1,75 @@ +package com.digitoolsolutions.app.torquevaultkmp + +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi +import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys +import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException +import io.github.aakira.napier.Napier +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +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.plugins.plugin +import io.ktor.client.request.header +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual fun createPlatformHttpClient( + tokenManager: TokenManager, + authApi: AuthApi? +): HttpClient { + val client = HttpClient(OkHttp) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + install(DefaultRequest) { + tokenManager.getAccessToken()?.let { + header("Authorization", "Bearer $it") + } + } + install(HttpTimeout) { requestTimeoutMillis = 30_000 } + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Napier.v(message, tag = "HTTP") + } + } + level = LogLevel.ALL // set LogLevel.NONE for release + } + } + + if (authApi != null) { + client.plugin(HttpSend).intercept { request -> + // Skip interceptor for refresh token request to avoid recursion + if (request.url.pathSegments.contains(ReferKeys.REFRESH_TOKEN)) { + return@intercept execute(request) + } + + val originalCall = execute(request) + if (originalCall.response.status == HttpStatusCode.Unauthorized) { + Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...") + val newToken = authApi.refreshToken() + if (newToken != null) { + request.headers["Authorization"] = "Bearer $newToken" + execute(request) + } else { + tokenManager.clearTokens() + throw ForceLogoutException() + } + } else { + originalCall + } + } + } + return client +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt new file mode 100644 index 0000000..1dcc915 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -0,0 +1,10 @@ +package com.digitoolsolutions.app.torquevaultkmp.di + +import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform +import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +actual val platformModule = module { + single { AppStoragePlatform(androidContext()) } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt index e16b8ce..49774a6 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt @@ -1,18 +1,25 @@ package com.digitoolsolutions.app.torquevaultkmp +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.digitoolsolutions.android.torquevault.ui.theme.TorqueVaultTheme import com.digitoolsolutions.app.torquevaultkmp.screens.MainScreen +import org.koin.compose.viewmodel.koinViewModel @Composable @Preview fun App() { - TorqueVaultTheme { + val viewModel = koinViewModel() + val savedTheme = viewModel.isDarkTheme.value + val isDarkTheme = savedTheme ?: isSystemInDarkTheme() + val isLoggedIn = viewModel.isLoggedIn() + + TorqueVaultTheme(darkTheme = isDarkTheme) { MainScreen( - isLoggedIn = false, - isDarkTheme = true, - onThemeToggle = {} + isDarkTheme = isDarkTheme, + onThemeToggle = { viewModel.toggleTheme(it) }, + isLoggedIn = isLoggedIn ) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt new file mode 100644 index 0000000..21eb3ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppViewModel.kt @@ -0,0 +1,27 @@ +package com.digitoolsolutions.app.torquevaultkmp + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys +import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import com.digitoolsolutions.app.torquevaultkmp.data.storage.load + +class AppViewModel( + private val tokenManager: TokenManager, + private val storage: AppStorage +) : ViewModel() { + + private val _isDarkTheme = mutableStateOf(storage.load(ReferKeys.THEME)) + val isDarkTheme: State = _isDarkTheme + + fun isLoggedIn(): Boolean { + return tokenManager.getRefreshToken() != null + } + + fun toggleTheme(isDark: Boolean) { + _isDarkTheme.value = isDark + storage.save(ReferKeys.THEME, isDark) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.kt new file mode 100644 index 0000000..a684906 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.kt @@ -0,0 +1,7 @@ +package com.digitoolsolutions.app.torquevaultkmp + +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi +import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import io.ktor.client.HttpClient + +expect fun createPlatformHttpClient(tokenManager: TokenManager, authApi: AuthApi?): HttpClient diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/AppBar.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/AppBar.kt index 08c71c5..7bf749f 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/AppBar.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/AppBar.kt @@ -2,11 +2,6 @@ package com.digitoolsolutions.app.torquevaultkmp.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.displayCutout -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.union import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,7 +25,6 @@ import torquevaultkmp.composeapp.generated.resources.baseline_menu_24 import torquevaultkmp.composeapp.generated.resources.menu_item_accessibility import torquevaultkmp.composeapp.generated.resources.navigation_item_accessibility - @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppBar( diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/NetworkModule.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/NetworkModule.kt new file mode 100644 index 0000000..3ee78c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/NetworkModule.kt @@ -0,0 +1,25 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network + +import com.digitoolsolutions.app.torquevaultkmp.utils.ApiException +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +object NetworkModule { + val client = HttpClient { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpTimeout) { requestTimeoutMillis = 30_000 } +// install(HttpResponseValidator) { +// validateResponse { response -> +// if (response.status.value >= 400) { +// throw ApiException(response.status, response.bodyAsText()) +// } +// } +// } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt new file mode 100644 index 0000000..3c651e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt @@ -0,0 +1,50 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.api + +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginResponseDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshResponseDto +import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType + + +class AuthApi( + private val client: HttpClient, + private val baseUrl: String, + private val tokenManager: TokenManager +) { + suspend fun login(username: String, password: String): Boolean { + return try { + println(">>>>> Check LOGIN Api") + val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") { + contentType(ContentType.Application.Json) + setBody(LoginRequestDto(username, password)) + }.body() + println(res) + tokenManager.saveTokens(res.access, res.refresh) + true + } catch (e: Exception) { + println(">>>>> [AuthApi] Exception: $e") + false + } + } + suspend fun refreshToken(): String? { + val refreshToken = tokenManager.getRefreshToken() ?: return null + return try { + val res: RefreshResponseDto = client.post("$baseUrl${Endpoints.AUTH_REFRESH}") { + contentType(ContentType.Application.Json) + setBody(RefreshRequestDto(refreshToken)) + }.body() + tokenManager.saveTokens(res.access, refreshToken) + res.access + } catch (e: Exception) { + tokenManager.clearTokens() + null // refresh token invalid + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt new file mode 100644 index 0000000..cbcb51c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt @@ -0,0 +1,8 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.api + +object Endpoints { + const val AUTH_TOKEN = "/token/" + const val AUTH_REFRESH = "/token/refresh/" + const val WORK_ORDERS = "/work-orders/able-v2/" + const val CONFIRM = "/confirm" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt new file mode 100644 index 0000000..475dc3f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt @@ -0,0 +1,27 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.api + +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.ConfirmRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +class WorkOrderApi( + private val client: HttpClient, + private val baseUrl: String +) { + suspend fun fetchAbleWorkOrders(page: Int): List { + return client.get("$baseUrl${Endpoints.WORK_ORDERS}") { + parameter("page", page) + }.body() + } + + suspend fun confirm(mac: String, type: Int) { + client.post("$baseUrl${Endpoints.CONFIRM}") { + setBody(ConfirmRequestDto(mac, type)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/AuthDto.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/AuthDto.kt new file mode 100644 index 0000000..11c44f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/AuthDto.kt @@ -0,0 +1,12 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequestDto(val username: String, val password: String) +@Serializable +data class RefreshRequestDto(val refresh: String) +@Serializable +data class LoginResponseDto(val access: String, val refresh: String) +@Serializable +data class RefreshResponseDto(val access: String) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt new file mode 100644 index 0000000..20a4940 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt @@ -0,0 +1,34 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class WorkOrderDto( + @SerialName("wo_id") val id: String, + val status: String, + val car: String? = null, + val make: String?, + @SerialName("license_plate") val licensePlate: String, + val services: Map?, + @SerialName("meas_result") val measResult: Map?, + @SerialName("meas_history") val measHistory: Map?, + @SerialName("finished_at") val finishedAt: String?, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String? = null, +) +@Serializable +data class ServiceDto( + val nut: Int?, + val status: Int, + val wheels: List?, + val wrench: String?, + @SerialName("torque_required") val torqueRequired: Double +) + +@Serializable +data class ConfirmRequestDto( + @SerialName("wrench_mac_addr") val mac: String, + val type: Int +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/AuthMapper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/AuthMapper.kt new file mode 100644 index 0000000..2bd94f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/AuthMapper.kt @@ -0,0 +1,24 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.mapper + +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginResponseDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshResponseDto +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LoginRequest +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LoginResponse +import com.digitoolsolutions.app.torquevaultkmp.domain.model.RefreshRequest +import com.digitoolsolutions.app.torquevaultkmp.domain.model.RefreshResponse + +fun LoginRequestDto.toDomain(): LoginRequest { + return LoginRequest(username,password) +} + +fun LoginResponseDto.toDomain(): LoginResponse { + return LoginResponse(access, refresh) +} +fun RefreshRequestDto.toDomain(): RefreshRequest { + return RefreshRequest(refresh) +} +fun RefreshResponseDto.toDomain(): RefreshResponse { + return RefreshResponse(access) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt new file mode 100644 index 0000000..c9304ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt @@ -0,0 +1,35 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.network.mapper + +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto +import com.digitoolsolutions.app.torquevaultkmp.domain.model.Service +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonPrimitive + +fun WorkOrderDto.toDomain(): WorkOrder { + return WorkOrder( + id = id, + status = status, + car = car, + make = make, + licensePlate = licensePlate, + services = services?.mapValues { (_, dto) -> + Service( + nut = dto.nut, + status = dto.status, + wheels = dto.wheels, + wrench = dto.wrench, + torqueRequired = dto.torqueRequired + ) + }, + measResult = measResult?.mapValues { (_, value) -> + value.jsonPrimitive.doubleOrNull ?: Double.NaN + }, + measHistory = measHistory?.mapValues { (_, value) -> + value.jsonPrimitive.doubleOrNull ?: Double.NaN + }, + finishedAt = finishedAt, + createdAt = createdAt, + updatedAt = updatedAt + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt new file mode 100644 index 0000000..03bb541 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt @@ -0,0 +1,8 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.repository + +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder + +interface WorkOrderRepository { + suspend fun fetchAbleWorkOrders(page: Int): List + suspend fun confirm(mac: String, type: Int) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt new file mode 100644 index 0000000..85bcc2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt @@ -0,0 +1,15 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.repository + +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi +import com.digitoolsolutions.app.torquevaultkmp.data.network.mapper.toDomain +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder +class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository { + override suspend fun fetchAbleWorkOrders(page: Int): List { + val dto = api.fetchAbleWorkOrders(page) + return dto.map { it.toDomain() } + } + + override suspend fun confirm(mac: String, type: Int) { + api.confirm(mac, type) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt new file mode 100644 index 0000000..a434e12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt @@ -0,0 +1,13 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.storage + +import kotlin.reflect.KClass + +interface AppStorage { + fun save(key: String, value: T) + fun load(key: String, type: KClass): T? + fun remove(key: String) +} + +inline fun AppStorage.load(key: String): T? { + return load(key, T::class) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/ReferKeys.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/ReferKeys.kt new file mode 100644 index 0000000..ac510fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/ReferKeys.kt @@ -0,0 +1,7 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.storage + +object ReferKeys { + const val ACCESS_TOKEN = "access_token" + const val REFRESH_TOKEN = "refresh_token" + const val THEME = "theme" +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt new file mode 100644 index 0000000..eeb3f3e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/TokenManager.kt @@ -0,0 +1,29 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.storage + +class TokenManager(private val storage: AppStorage) { + private var accessToken: String? = null + private var refreshToken: String? = null + + fun getAccessToken(): String? { + if (accessToken == null) accessToken = storage.load(ReferKeys.ACCESS_TOKEN) + return accessToken + } + fun getRefreshToken(): String? { + if (refreshToken == null) refreshToken = storage.load(ReferKeys.REFRESH_TOKEN) + return refreshToken + } + + fun saveTokens(access: String, refresh: String) { + accessToken = access + refreshToken = refresh + storage.save(ReferKeys.ACCESS_TOKEN, access) + storage.save(ReferKeys.REFRESH_TOKEN, refresh) + } + + fun clearTokens() { + accessToken = null + refreshToken = null + storage.remove(ReferKeys.ACCESS_TOKEN) + storage.remove(ReferKeys.REFRESH_TOKEN) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt index 4327187..d45f6ab 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt @@ -1,4 +1,16 @@ package com.digitoolsolutions.app.torquevaultkmp.di -class Koin { +import org.koin.core.context.startKoin +import org.koin.dsl.KoinAppDeclaration + +fun initKoin(config: KoinAppDeclaration? = null) { + startKoin { + config?.invoke(this) + modules( + appModule, + networkModule, + viewModelModule, + platformModule + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt index 8944053..e0ea012 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt @@ -1,5 +1,46 @@ package com.digitoolsolutions.app.torquevaultkmp.di +import com.digitoolsolutions.app.torquevaultkmp.AppViewModel +import com.digitoolsolutions.app.torquevaultkmp.createPlatformHttpClient +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi +import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository +import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl +import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase +import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel +import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel +import org.koin.core.module.Module +import org.koin.core.module.dsl.factoryOf +import org.koin.core.qualifier.named import org.koin.dsl.module -val homeModule = module { } \ No newline at end of file +expect val platformModule: Module + +val appModule = module { + // TokenManager use AppStorage + single { TokenManager(get()) } +} + +val networkModule = module { + single { "http://digitoolsolutions.synology.me:3000/api/v1" } // base URL + + // Auth client (no interceptor to avoid circular dependency and recursion) + single(named("authClient")) { createPlatformHttpClient(get(), null) } + + // AuthApi uses the auth client + single { AuthApi(get(named("authClient")), get(), get()) } + + // Main client uses AuthApi for its interceptor + single { createPlatformHttpClient(get(), get()) } + + single { WorkOrderApi(get(), get()) } + single { WorkOrderRepositoryImpl(get()) } + factory { FetchWorkOrdersUseCase(get()) } +} + +val viewModelModule = module { + factoryOf(::HomeViewModel) + factoryOf(::AuthViewModel) + factoryOf(::AppViewModel) +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/Auth.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/Auth.kt new file mode 100644 index 0000000..d5c8761 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/Auth.kt @@ -0,0 +1,6 @@ +package com.digitoolsolutions.app.torquevaultkmp.domain.model + +data class LoginRequest(val username: String, val password: String) +data class RefreshRequest(val refresh: String) +data class LoginResponse(val access: String, val refresh: String) +data class RefreshResponse(val access: String) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt new file mode 100644 index 0000000..d96620a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt @@ -0,0 +1,44 @@ +package com.digitoolsolutions.app.torquevaultkmp.domain.model +val wheelsCode = mapOf( + "DF" to 0b0001, + "PF" to 0b0010, + "DR" to 0b0100, + "PR" to 0b1000, + "DRO" to 0b00010000, + "PRO" to 0b100000 +) + +fun WorkOrder.computeWheelsNuts(): Int { + var wNut = 0 + var nutCount = 0 + val wheelSer = services?.get("0") + + wheelSer?.wheels?.forEach { wheelStr -> + if (wheelStr.isNotBlank()) { + wNut = wNut or (wheelsCode[wheelStr] ?: 0) + nutCount++ + } + } + return (wNut shl 8) or nutCount +} + +data class WorkOrder( + val id: String, + val status: String, + val car: String?, + val make: String?, + val licensePlate: String, + val services: Map?, + val measResult: Map?, + val measHistory: Map?, + val finishedAt: String?, + val createdAt: String, + val updatedAt: String? +) +data class Service( + val nut: Int?, + val status: Int, + val wheels: List?, + val wrench: String?, + val torqueRequired: Double +) diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt new file mode 100644 index 0000000..784759b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt @@ -0,0 +1,10 @@ +package com.digitoolsolutions.app.torquevaultkmp.domain.usecase + +import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder + +class FetchWorkOrdersUseCase(private val repository: WorkOrderRepository) { + suspend operator fun invoke(page: Int): List { + return repository.fetchAbleWorkOrders(page) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt index b568228..a7aefb9 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt @@ -28,9 +28,9 @@ import org.jetbrains.compose.resources.painterResource import org.koin.compose.viewmodel.koinViewModel @Composable fun MainScreen( - isLoggedIn: Boolean, isDarkTheme: Boolean, onThemeToggle: (Boolean) -> Unit, + isLoggedIn: Boolean, ) { val destinations = listOf( HomeDestination, @@ -77,7 +77,7 @@ fun MainScreen( ) { innerPadding -> NavHost( navController = navController, - startDestination = HomeDestination.route, + startDestination = if (isLoggedIn) HomeDestination.route else AuthDestination.route, modifier = Modifier.padding(innerPadding).fillMaxSize() ) { composable(AuthDestination.route) { LoginScreen(navController) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt new file mode 100644 index 0000000..5d9ca75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/AuthViewModel.kt @@ -0,0 +1,43 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed class LoginUiState { + object Idle : LoginUiState() + object Loading : LoginUiState() + object Success : LoginUiState() + data class Error(val message: String) : LoginUiState() +} + +class AuthViewModel( + private val authApi: AuthApi +) : ViewModel() { + + private val _loginState = MutableStateFlow(LoginUiState.Idle) + val loginState = _loginState.asStateFlow() + + fun login(username: String, password: String) { + viewModelScope.launch { + _loginState.value = LoginUiState.Loading + try { + val success = authApi.login(username, password) + if (success) { + _loginState.value = LoginUiState.Success + } else { + _loginState.value = LoginUiState.Error("Login failed: Invalid credentials") + } + } catch (e: Exception) { + _loginState.value = LoginUiState.Error("Error: ${e.message}") + } + } + } + + fun resetState() { + _loginState.value = LoginUiState.Idle + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt index e7bf1a7..c7070f1 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/auth/LoginScreen.kt @@ -54,6 +54,7 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.btn_login import torquevaultkmp.composeapp.generated.resources.dts_text @@ -64,17 +65,17 @@ import torquevaultkmp.composeapp.generated.resources.server_url_placeholder @Composable fun LoginScreen( - navController: NavController, -// viewModel: AuthViewModel = hiltViewModel() + navController: NavController ) { -// val loginState by viewModel.loginState.collectAsState() + val viewModel = koinViewModel() + val loginState by viewModel.loginState.collectAsState() LoginScreenContent( -// viewModel = viewModel, -// loginState = loginState, + viewModel = viewModel, + loginState = loginState, onLogin = { serverUrl, username, password -> // viewModel.updateServerUrl(serverUrl) -// viewModel.login(username, password) + viewModel.login(username, password) }, onLoginSuccess = { navController.navigate(HomeDestination.route) @@ -85,8 +86,8 @@ fun LoginScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreenContent( -// viewModel: AuthViewModel, -// loginState: LoginUiState, + viewModel: AuthViewModel, + loginState: LoginUiState, onLogin: (String, String, String) -> Unit, onLoginSuccess: () -> Unit ) { @@ -99,14 +100,14 @@ fun LoginScreenContent( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() -// LaunchedEffect(loginState) { -// if (loginState is LoginUiState.Error) { -// snackbarHostState.showSnackbar(loginState.message) -// } -// if (loginState is LoginUiState.Success) { -// onLoginSuccess() -// } -// } + LaunchedEffect(loginState) { + if (loginState is LoginUiState.Error) { + snackbarHostState.showSnackbar(loginState.message) + } + if (loginState is LoginUiState.Success) { + onLoginSuccess() + } + } Scaffold( modifier = Modifier.imePadding(), snackbarHost = { SnackbarHost(snackbarHostState) }) @@ -203,18 +204,18 @@ fun LoginScreenContent( else onLogin(serverUrl.trim(), username.trim(), password.trim()) }, -// enabled = loginState !is LoginUiState.Loading + enabled = loginState !is LoginUiState.Loading ) { Text(stringResource(Res.string.btn_login)) -// if (loginState is LoginUiState.Loading) { -// Spacer(modifier = Modifier.width(16.dp)) -// CircularProgressIndicator( -// modifier = Modifier.size(20.dp), -// color = Color.Blue, -// strokeWidth = 2.dp -// ) -// -// } + if (loginState is LoginUiState.Loading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.Blue, + strokeWidth = 2.dp + ) + + } } } } @@ -224,8 +225,8 @@ fun LoginScreenContent( @Composable private fun LoginScreenPreview() { LoginScreenContent( -// viewModel = viewModel(), -// loginState = LoginUiState.Idle, + viewModel = viewModel(), + loginState = LoginUiState.Idle, onLogin = { _, _, _ -> }, onLoginSuccess = {} ) diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt index 3af0187..d0cce46 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,9 +42,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.digitoolsolutions.app.torquevaultkmp.components.AppBar +import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination import com.digitoolsolutions.app.torquevaultkmp.screens.home.views.HomeEmpty import com.digitoolsolutions.app.torquevaultkmp.screens.home.views.HomeItem +import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.home_title //import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -65,31 +71,49 @@ fun HomeScreen( ) { val snackbarHostState = remember { SnackbarHostState() } // val connectedDevice by bleScannerViewModel.connectedDevices.collectAsState(initial = emptyList()) + val viewModel = koinViewModel() + val workOrders by viewModel.workOrders.collectAsState() - Scaffold ( + LaunchedEffect(Unit) { + viewModel.loadWorkOrders() + } + + LaunchedEffect(viewModel) { + viewModel.error.collectLatest { error -> + if (error is ForceLogoutException) { + navController.navigate(AuthDestination.route) { + popUpTo(0) { inclusive = true } + } + } else { + snackbarHostState.showSnackbar(error.message ?: "Unknown error") + } + } + } + + Scaffold( modifier = Modifier.imePadding(), topBar = { AppBar( - title = {Text(stringResource(Res.string.home_title))}, + title = { Text(stringResource(Res.string.home_title)) }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> - Column ( + Column( modifier = Modifier .fillMaxSize() .imePadding() .padding(padding) .padding(horizontal = 16.dp, vertical = 12.dp), ) { -// if(connectedDevice.isEmpty()) -// HomeEmpty() -// else -// LazyColumn { -// items(connectedDevice) { device -> -// HomeItem(device) -// } -// } + if (workOrders.isEmpty()) + HomeEmpty() + else + LazyColumn { + items(workOrders) { workOrder -> + HomeItem(workOrder) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt new file mode 100644 index 0000000..0a879c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeViewModel.kt @@ -0,0 +1,33 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder +import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase +import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class HomeViewModel( + private val fetchWorkOrdersUseCase: FetchWorkOrdersUseCase +): ViewModel() { + private val _workOrders = MutableStateFlow>(emptyList()) + val workOrders = _workOrders.asStateFlow() + + private val _error = MutableSharedFlow() + val error = _error.asSharedFlow() + + fun loadWorkOrders() { + viewModelScope.launch { + try { + val result = fetchWorkOrdersUseCase(page = 1) + _workOrders.value = result + } catch (e: Exception) { + _error.emit(e) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ApiException.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ApiException.kt new file mode 100644 index 0000000..3418223 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ApiException.kt @@ -0,0 +1,11 @@ +package com.digitoolsolutions.app.torquevaultkmp.utils + +import io.ktor.http.HttpStatusCode + +class ApiException(val status: HttpStatusCode, val body: String) : Exception( + "API error ${status.value}: $body" +) + +class RetryWithNewTokenException : Exception(">>>>> Access token expired, retry with new token") +class ForceLogoutException : Exception(">>>>> Refresh token expired, force logout") + diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/NoOpAntilog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/NoOpAntilog.kt new file mode 100644 index 0000000..5ecb272 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/NoOpAntilog.kt @@ -0,0 +1,9 @@ +package com.digitoolsolutions.app.torquevaultkmp.utils + +import io.github.aakira.napier.Antilog +import io.github.aakira.napier.LogLevel + +class NoOpAntilog : Antilog() { + override fun isEnable(priority: LogLevel, tag: String?): Boolean = false + override fun performLog(priority: LogLevel, tag: String?, throwable: Throwable?, message: String?) {} +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt new file mode 100644 index 0000000..c5469ab --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt @@ -0,0 +1,31 @@ +package com.digitoolsolutions.app.torquevaultkmp + +import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import platform.Foundation.NSUserDefaults +import kotlin.reflect.KClass + +class AppStoragePlatform : AppStorage { + private val defaults = NSUserDefaults.standardUserDefaults() + + override fun save(key: String, value: T) { + when (value) { + is String -> defaults.setObject(value, forKey = key) + is Int -> defaults.setInteger(value.toLong(), forKey = key) + is Boolean -> defaults.setBool(value, forKey = key) + else -> throw IllegalArgumentException("Unsupported type") + } + } + + @Suppress("UNCHECKED_CAST") + override fun load(key: String, type: KClass): T? { + return when (type) { + String::class -> defaults.stringForKey(key) as T? + Int::class -> defaults.integerForKey(key).toInt() as T? + Boolean::class -> defaults.boolForKey(key) as T? + else -> throw IllegalArgumentException("Unsupported type") + } + } + override fun remove(key: String) { + defaults.removeObjectForKey(key) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainViewController.kt index 31facc2..86e90fe 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/MainViewController.kt @@ -1,5 +1,14 @@ package com.digitoolsolutions.app.torquevaultkmp import androidx.compose.ui.window.ComposeUIViewController +import com.digitoolsolutions.app.torquevaultkmp.di.initKoin +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier -fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file +fun MainViewController() = ComposeUIViewController( + configure = { + // Add logger lib + Napier.base(DebugAntilog()) // NoOpAntilog() for release + initKoin() + } +) { App() } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt new file mode 100644 index 0000000..220eaaf --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/NetworkEngine.ios.kt @@ -0,0 +1,76 @@ +package com.digitoolsolutions.app.torquevaultkmp + +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi +import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys +import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException +import io.github.aakira.napier.Napier +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +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.plugins.plugin +import io.ktor.client.request.header +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +actual fun createPlatformHttpClient( + tokenManager: TokenManager, + authApi: AuthApi? +): HttpClient { + val client = HttpClient(Darwin) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + install(DefaultRequest) { + tokenManager.getAccessToken()?.let { + header("Authorization", "Bearer $it") + } + } + install(HttpTimeout) { requestTimeoutMillis = 30_000 } + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Napier.v(message, tag = "HTTP") + } + } + level = LogLevel.ALL // set LogLevel.NONE for release + } + } + + if (authApi != null) { + client.plugin(HttpSend).intercept { request -> + // Skip interceptor for refresh token request to avoid recursion + if (request.url.pathSegments.contains(ReferKeys.REFRESH_TOKEN)) { + return@intercept execute(request) + } + + val originalCall = execute(request) + if (originalCall.response.status == HttpStatusCode.Unauthorized) { + println(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...") + val newToken = authApi.refreshToken() + if (newToken != null) { + request.headers["Authorization"] = "Bearer $newToken" + execute(request) + } else { + tokenManager.clearTokens() + throw ForceLogoutException() + } + } else { + originalCall + } + } + } + + return client +} diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt new file mode 100644 index 0000000..9732439 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -0,0 +1,9 @@ +package com.digitoolsolutions.app.torquevaultkmp.di + +import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform +import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import org.koin.dsl.module + +actual val platformModule = module { + single { AppStoragePlatform() } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ebee39..297314b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,11 +14,10 @@ junit = "4.13.2" kotlin = "2.3.21" material3 = "1.10.0-alpha05" koin-bom = "4.2.1" +ktor = "3.4.3" material-icons-extended = "1.7.3" navigation-compose = "2.9.2" -compose-multiplatform-adaptive = "1.3.0-alpha07" -compose-multiplatform-lifecycle = "2.10.0" - +logger = "2.7.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -50,13 +49,27 @@ koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } koin-ktor = { module = "io.insert-koin:koin-ktor" } koin-test = { module = "io.insert-koin:koin-test" } - -#compose-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } -#androidx-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +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" } +logger-napier = { module = "io.github.aakira:napier", version.ref = "logger" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } \ No newline at end of file +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} + +[bundles] +ktor = [ + "ktor-client-core", + "ktor-client-content-negotiation", +# "ktor-client-auth", + "ktor-client-logging", + "ktor-serialization-kotlinx-json" +] \ No newline at end of file diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1..95a2efb 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,19 @@ CADisableMinimumFrameDurationOnPhone + + NSAppTransportSecurity + + NSExceptionDomains + + digitoolsolutions.synology.me + + NSIncludesSubdomains + + NSTemporaryExceptionAllowsInsecureHTTPLoads + + + +