From e7c381a9536701626c9408d3ced2fe9ff1185164 Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Fri, 15 May 2026 17:56:04 +0700 Subject: [PATCH] Handle logic login, save tokens, change theme, change server url --- .../composeResources/values/strings.xml | 2 +- .../data/network/api/AuthApi.kt | 4 +- .../data/network/api/WorkOrderApi.kt | 6 +- .../torquevaultkmp/data/storage/ReferKeys.kt | 2 + .../data/storage/TokenManager.kt | 34 +++++++++++ .../app/torquevaultkmp/di/Modules.kt | 8 +-- .../screens/auth/AuthViewModel.kt | 12 +++- .../screens/auth/LoginScreen.kt | 57 +++++++++++++------ .../screens/settings/SettingScreen.kt | 29 +++++----- .../screens/settings/SettingViewModel.kt | 46 +++++++++++++++ 10 files changed, 160 insertions(+), 40 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingViewModel.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a8d5ffc..41a7e7c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -20,7 +20,7 @@ Auto-connect device Server URL - Ex: http://192.168.1.6:8001 or https://example.com + Ex: https://example.com Username Password Login 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 index a0e6b19..9fccb14 100644 --- 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 @@ -15,9 +15,11 @@ import io.ktor.http.contentType class AuthApi( private val client: HttpClient, - private val baseUrl: String, private val tokenManager: TokenManager ) { + private val baseUrl: String? + get() = tokenManager.getServerUrl() + suspend fun login(username: String, password: String): Boolean { return try { val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") { 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 index 475dc3f..971b2cd 100644 --- 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 @@ -2,6 +2,7 @@ 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 com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get @@ -11,8 +12,11 @@ import io.ktor.client.request.setBody class WorkOrderApi( private val client: HttpClient, - private val baseUrl: String + private val tokenManager: TokenManager ) { + private val baseUrl: String? + get() = tokenManager.getServerUrl() + suspend fun fetchAbleWorkOrders(page: Int): List { return client.get("$baseUrl${Endpoints.WORK_ORDERS}") { parameter("page", page) 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 index ac510fd..c86da1b 100644 --- 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 @@ -3,5 +3,7 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage object ReferKeys { const val ACCESS_TOKEN = "access_token" const val REFRESH_TOKEN = "refresh_token" + const val SERVER_URL = "server_url" const val THEME = "theme" + const val DEVICE_AUTO_CONNECT = "device_auto_connect" } 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 index eeb3f3e..9214f98 100644 --- 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 @@ -3,6 +3,9 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage class TokenManager(private val storage: AppStorage) { private var accessToken: String? = null private var refreshToken: String? = null + + // Cache for the full API URL + private var cachedFullUrl: String? = null fun getAccessToken(): String? { if (accessToken == null) accessToken = storage.load(ReferKeys.ACCESS_TOKEN) @@ -13,6 +16,27 @@ class TokenManager(private val storage: AppStorage) { return refreshToken } + /** + * Returns the full API URL (e.g., http://domain:3000/api/v1) + */ + fun getServerUrl(): String? { + if (cachedFullUrl != null) return cachedFullUrl + + val base: String = storage.load(ReferKeys.SERVER_URL) ?: "" + if (base.isEmpty()) return null + + val url = base.trim().trimEnd('/') + cachedFullUrl = if (url.endsWith("/api/v1")) url else "$url/api/v1" + return cachedFullUrl + } + + /** + * Returns the base domain for UI display (e.g., http://domain:3000) + */ + fun getBaseUrl(): String? { + return storage.load(ReferKeys.SERVER_URL) + } + fun saveTokens(access: String, refresh: String) { accessToken = access refreshToken = refresh @@ -20,6 +44,16 @@ class TokenManager(private val storage: AppStorage) { storage.save(ReferKeys.REFRESH_TOKEN, refresh) } + fun saveServerUrl(url: String) { + // Clean the input: remove trailing slashes and redundant /api/v1 + val base = url.trim().trimEnd('/').removeSuffix("/api/v1").trimEnd('/') + + storage.save(ReferKeys.SERVER_URL, base) + + // Invalidate cache so it's recomputed on next call + cachedFullUrl = if (base.isEmpty()) null else "$base/api/v1" + } + fun clearTokens() { accessToken = null refreshToken = null 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 e0ea012..f9a107c 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 @@ -10,6 +10,7 @@ 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 com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.qualifier.named @@ -23,13 +24,11 @@ val appModule = module { } 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()) } + single { AuthApi(get(named("authClient")), get()) } // Main client uses AuthApi for its interceptor single { createPlatformHttpClient(get(), get()) } @@ -41,6 +40,7 @@ val networkModule = module { val viewModelModule = module { factoryOf(::HomeViewModel) - factoryOf(::AuthViewModel) + factory { AuthViewModel(get(), get()) } factoryOf(::AppViewModel) + factoryOf(::SettingViewModel) } 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 index 5d9ca75..f3b0132 100644 --- 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 @@ -3,6 +3,7 @@ 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 com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -15,15 +16,24 @@ sealed class LoginUiState { } class AuthViewModel( - private val authApi: AuthApi + private val authApi: AuthApi, + private val tokenManager: TokenManager ) : ViewModel() { private val _loginState = MutableStateFlow(LoginUiState.Idle) val loginState = _loginState.asStateFlow() + private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "") + val serverUrl = _serverUrl.asStateFlow() + + fun onChangeServerUrl(url: String) { + _serverUrl.value = url + } + fun login(username: String, password: String) { viewModelScope.launch { _loginState.value = LoginUiState.Loading + tokenManager.saveServerUrl(_serverUrl.value) try { val success = authApi.login(username, password) if (success) { 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 c7070f1..eac16a6 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 @@ -2,6 +2,7 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.auth import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,9 +14,11 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -42,7 +45,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -69,16 +74,20 @@ fun LoginScreen( ) { val viewModel = koinViewModel() val loginState by viewModel.loginState.collectAsState() + val serverUrl by viewModel.serverUrl.collectAsState() LoginScreenContent( viewModel = viewModel, loginState = loginState, - onLogin = { serverUrl, username, password -> -// viewModel.updateServerUrl(serverUrl) + serverUrl = serverUrl, + onLogin = { username, password -> viewModel.login(username, password) }, onLoginSuccess = { - navController.navigate(HomeDestination.route) + navController.navigate(HomeDestination.route) { + popUpTo(AuthDestination.route) { inclusive = true } + launchSingleTop = true + } } ) } @@ -88,12 +97,12 @@ fun LoginScreen( fun LoginScreenContent( viewModel: AuthViewModel, loginState: LoginUiState, - onLogin: (String, String, String) -> Unit, + serverUrl: String, + onLogin: (String, String) -> Unit, onLoginSuccess: () -> Unit ) { val focusManager = LocalFocusManager.current -// val serverUrl by viewModel.serverUrl.collectAsState() - val serverUrl = "" + val keyboardController = LocalSoftwareKeyboardController.current var username by remember { mutableStateOf("admin") } var password by remember { mutableStateOf("admin") } var passwordVisible by remember { mutableStateOf(false) } @@ -109,14 +118,20 @@ fun LoginScreenContent( } } Scaffold( - modifier = Modifier.imePadding(), - snackbarHost = { SnackbarHost(snackbarHostState) }) - { padding -> + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> Column( modifier = Modifier .background(MaterialTheme.colorScheme.background) .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally @@ -144,7 +159,7 @@ fun LoginScreenContent( onNext = { focusManager.moveFocus(FocusDirection.Down) } ), value = serverUrl, - onValueChange = { /** viewModel.onChangeServerUrl(it) */ }, + onValueChange = { viewModel.onChangeServerUrl(it) }, label = { Text(stringResource(Res.string.lbl_server_url)) }, placeholder = { Text(stringResource(Res.string.server_url_placeholder)) } @@ -179,12 +194,15 @@ fun LoginScreenContent( keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { - if (username.isBlank() || password.isBlank()) + if (username.isBlank() || password.isBlank()) { scope.launch { snackbarHostState.showSnackbar("Username and Password is required!") } - else - onLogin(serverUrl, username, password) + } else { + onLogin(username, password) + keyboardController?.hide() + focusManager.clearFocus() + } } ), value = password, @@ -197,12 +215,15 @@ fun LoginScreenContent( .width(150.dp) .height(45.dp), onClick = { - if (username.isBlank() || password.isBlank()) + if (username.isBlank() || password.isBlank()) { scope.launch { snackbarHostState.showSnackbar("Username and password is required!") } - else - onLogin(serverUrl.trim(), username.trim(), password.trim()) + } else { + onLogin(username.trim(), password.trim()) + keyboardController?.hide() + focusManager.clearFocus() + } }, enabled = loginState !is LoginUiState.Loading ) { @@ -214,7 +235,6 @@ fun LoginScreenContent( color = Color.Blue, strokeWidth = 2.dp ) - } } } @@ -227,7 +247,8 @@ private fun LoginScreenPreview() { LoginScreenContent( viewModel = viewModel(), loginState = LoginUiState.Idle, - onLogin = { _, _, _ -> }, + serverUrl = "http://localhost:3000", + onLogin = {_, _ -> }, onLoginSuccess = {} ) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt index 9eabec3..d72b795 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt @@ -32,7 +32,9 @@ 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 kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel import org.jetbrains.compose.resources.stringResource import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.auto_connect @@ -53,29 +55,28 @@ fun SettingScreen( navController: NavController, isDarkTheme: Boolean, onThemeToggle: (Boolean) -> Unit, -// viewModel: SettingViewModel = hiltViewModel() ) { -// val serverUrl by viewModel.serverUrl.collectAsState() -// val token by viewModel.refreshToken.collectAsState() -// val autoConnect by viewModel.autoConnect.collectAsState() - val serverUrl = "" - val token = "" - val autoConnect = "" + val viewModel = koinViewModel() + val serverUrl by viewModel.serverUrl.collectAsState() + val token by viewModel.refreshToken.collectAsState() + val autoConnect by viewModel.autoConnect.collectAsState() SettingContent( isDarkTheme = isDarkTheme, onThemeToggle = onThemeToggle, serverUrl = serverUrl, - onServerUrlChange = { /** viewModel.updateServerUrl(it) */ }, + onServerUrlChange = { viewModel.updateServerUrl(it) }, token = token, - autoConnect = autoConnect == "true", - onTokenChange = { /** viewModel.updateRefreshToken(it) */ }, - onSaveSettings = { /** viewModel.saveSettings() */ }, + autoConnect = autoConnect, + onTokenChange = { viewModel.updateRefreshToken(it) }, + onSaveSettings = { viewModel.saveSettings() }, onLogout = { -// viewModel.logout() -// navController.navigate(AuthDestination.route) + viewModel.logout() + navController.navigate(AuthDestination.route) { + popUpTo(0) + } }, - onChangeAutoConnect = { /** viewModel.updateAutoConnect(it) */ } + onChangeAutoConnect = { viewModel.updateAutoConnect(it) } ) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingViewModel.kt new file mode 100644 index 0000000..a72ae6e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingViewModel.kt @@ -0,0 +1,46 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.settings + +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 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class SettingViewModel( + private val tokenManager: TokenManager, + private val storage: AppStorage +) : ViewModel() { + private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "") + val serverUrl: StateFlow = _serverUrl.asStateFlow() + + private val _refreshToken = MutableStateFlow(tokenManager.getRefreshToken() ?: "") + val refreshToken: StateFlow = _refreshToken.asStateFlow() + + private val _autoConnect = MutableStateFlow(storage.load(ReferKeys.DEVICE_AUTO_CONNECT) ?: false) + val autoConnect: StateFlow = _autoConnect.asStateFlow() + + fun updateServerUrl(url: String) { + _serverUrl.value = url + } + + fun updateRefreshToken(token: String) { + _refreshToken.value = token + } + + fun saveSettings() { + tokenManager.saveServerUrl(_serverUrl.value) + tokenManager.saveTokens("", _refreshToken.value) + } + + fun logout() { + tokenManager.clearTokens() + } + + fun updateAutoConnect(enabled: Boolean) { + _autoConnect.value = enabled + storage.save(ReferKeys.DEVICE_AUTO_CONNECT, enabled) + } +}