Handle logic login, save tokens, change theme, change server url

This commit is contained in:
2026-05-15 17:56:04 +07:00
parent 99ebe57871
commit e7c381a953
10 changed files with 160 additions and 40 deletions
@@ -20,7 +20,7 @@
<string name="auto_connect">Auto-connect device</string> <string name="auto_connect">Auto-connect device</string>
<string name="lbl_server_url">Server URL</string> <string name="lbl_server_url">Server URL</string>
<string name="server_url_placeholder">Ex: http://192.168.1.6:8001 or https://example.com</string> <string name="server_url_placeholder">Ex: https://example.com</string>
<string name="lbl_username">Username</string> <string name="lbl_username">Username</string>
<string name="lbl_password">Password</string> <string name="lbl_password">Password</string>
<string name="btn_login">Login</string> <string name="btn_login">Login</string>
@@ -15,9 +15,11 @@ import io.ktor.http.contentType
class AuthApi( class AuthApi(
private val client: HttpClient, private val client: HttpClient,
private val baseUrl: String,
private val tokenManager: TokenManager private val tokenManager: TokenManager
) { ) {
private val baseUrl: String?
get() = tokenManager.getServerUrl()
suspend fun login(username: String, password: String): Boolean { suspend fun login(username: String, password: String): Boolean {
return try { return try {
val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") { val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") {
@@ -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.ConfirmRequestDto
import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto 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.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
@@ -11,8 +12,11 @@ import io.ktor.client.request.setBody
class WorkOrderApi( class WorkOrderApi(
private val client: HttpClient, 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<WorkOrderDto> { suspend fun fetchAbleWorkOrders(page: Int): List<WorkOrderDto> {
return client.get("$baseUrl${Endpoints.WORK_ORDERS}") { return client.get("$baseUrl${Endpoints.WORK_ORDERS}") {
parameter("page", page) parameter("page", page)
@@ -3,5 +3,7 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage
object ReferKeys { object ReferKeys {
const val ACCESS_TOKEN = "access_token" const val ACCESS_TOKEN = "access_token"
const val REFRESH_TOKEN = "refresh_token" const val REFRESH_TOKEN = "refresh_token"
const val SERVER_URL = "server_url"
const val THEME = "theme" const val THEME = "theme"
const val DEVICE_AUTO_CONNECT = "device_auto_connect"
} }
@@ -3,6 +3,9 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage
class TokenManager(private val storage: AppStorage) { class TokenManager(private val storage: AppStorage) {
private var accessToken: String? = null private var accessToken: String? = null
private var refreshToken: String? = null private var refreshToken: String? = null
// Cache for the full API URL
private var cachedFullUrl: String? = null
fun getAccessToken(): String? { fun getAccessToken(): String? {
if (accessToken == null) accessToken = storage.load(ReferKeys.ACCESS_TOKEN) if (accessToken == null) accessToken = storage.load(ReferKeys.ACCESS_TOKEN)
@@ -13,6 +16,27 @@ class TokenManager(private val storage: AppStorage) {
return refreshToken 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) { fun saveTokens(access: String, refresh: String) {
accessToken = access accessToken = access
refreshToken = refresh refreshToken = refresh
@@ -20,6 +44,16 @@ class TokenManager(private val storage: AppStorage) {
storage.save(ReferKeys.REFRESH_TOKEN, refresh) 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() { fun clearTokens() {
accessToken = null accessToken = null
refreshToken = null refreshToken = null
@@ -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.domain.usecase.FetchWorkOrdersUseCase
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel 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.Module
import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.factoryOf
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
@@ -23,13 +24,11 @@ val appModule = module {
} }
val networkModule = module { val networkModule = module {
single { "http://digitoolsolutions.synology.me:3000/api/v1" } // base URL
// Auth client (no interceptor to avoid circular dependency and recursion) // Auth client (no interceptor to avoid circular dependency and recursion)
single(named("authClient")) { createPlatformHttpClient(get(), null) } single(named("authClient")) { createPlatformHttpClient(get(), null) }
// AuthApi uses the auth client // AuthApi uses the auth client
single<AuthApi> { AuthApi(get(named("authClient")), get(), get()) } single<AuthApi> { AuthApi(get(named("authClient")), get()) }
// Main client uses AuthApi for its interceptor // Main client uses AuthApi for its interceptor
single { createPlatformHttpClient(get(), get()) } single { createPlatformHttpClient(get(), get()) }
@@ -41,6 +40,7 @@ val networkModule = module {
val viewModelModule = module { val viewModelModule = module {
factoryOf(::HomeViewModel) factoryOf(::HomeViewModel)
factoryOf(::AuthViewModel) factory { AuthViewModel(get(), get()) }
factoryOf(::AppViewModel) factoryOf(::AppViewModel)
factoryOf(::SettingViewModel)
} }
@@ -3,6 +3,7 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi 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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -15,15 +16,24 @@ sealed class LoginUiState {
} }
class AuthViewModel( class AuthViewModel(
private val authApi: AuthApi private val authApi: AuthApi,
private val tokenManager: TokenManager
) : ViewModel() { ) : ViewModel() {
private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle) private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val loginState = _loginState.asStateFlow() 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) { fun login(username: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
_loginState.value = LoginUiState.Loading _loginState.value = LoginUiState.Loading
tokenManager.saveServerUrl(_serverUrl.value)
try { try {
val success = authApi.login(username, password) val success = authApi.login(username, password)
if (success) { if (success) {
@@ -2,6 +2,7 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.auth
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff 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.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager 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.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@@ -69,16 +74,20 @@ fun LoginScreen(
) { ) {
val viewModel = koinViewModel<AuthViewModel>() val viewModel = koinViewModel<AuthViewModel>()
val loginState by viewModel.loginState.collectAsState() val loginState by viewModel.loginState.collectAsState()
val serverUrl by viewModel.serverUrl.collectAsState()
LoginScreenContent( LoginScreenContent(
viewModel = viewModel, viewModel = viewModel,
loginState = loginState, loginState = loginState,
onLogin = { serverUrl, username, password -> serverUrl = serverUrl,
// viewModel.updateServerUrl(serverUrl) onLogin = { username, password ->
viewModel.login(username, password) viewModel.login(username, password)
}, },
onLoginSuccess = { onLoginSuccess = {
navController.navigate(HomeDestination.route) navController.navigate(HomeDestination.route) {
popUpTo(AuthDestination.route) { inclusive = true }
launchSingleTop = true
}
} }
) )
} }
@@ -88,12 +97,12 @@ fun LoginScreen(
fun LoginScreenContent( fun LoginScreenContent(
viewModel: AuthViewModel, viewModel: AuthViewModel,
loginState: LoginUiState, loginState: LoginUiState,
onLogin: (String, String, String) -> Unit, serverUrl: String,
onLogin: (String, String) -> Unit,
onLoginSuccess: () -> Unit onLoginSuccess: () -> Unit
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// val serverUrl by viewModel.serverUrl.collectAsState() val keyboardController = LocalSoftwareKeyboardController.current
val serverUrl = ""
var username by remember { mutableStateOf("admin") } var username by remember { mutableStateOf("admin") }
var password by remember { mutableStateOf("admin") } var password by remember { mutableStateOf("admin") }
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
@@ -109,14 +118,20 @@ fun LoginScreenContent(
} }
} }
Scaffold( Scaffold(
modifier = Modifier.imePadding(), snackbarHost = { SnackbarHost(snackbarHostState) }
snackbarHost = { SnackbarHost(snackbarHostState) }) ) { padding ->
{ padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.fillMaxSize() .fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
.padding(padding) .padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -144,7 +159,7 @@ fun LoginScreenContent(
onNext = { focusManager.moveFocus(FocusDirection.Down) } onNext = { focusManager.moveFocus(FocusDirection.Down) }
), ),
value = serverUrl, value = serverUrl,
onValueChange = { /** viewModel.onChangeServerUrl(it) */ }, onValueChange = { viewModel.onChangeServerUrl(it) },
label = { Text(stringResource(Res.string.lbl_server_url)) }, label = { Text(stringResource(Res.string.lbl_server_url)) },
placeholder = { Text(stringResource(Res.string.server_url_placeholder)) } placeholder = { Text(stringResource(Res.string.server_url_placeholder)) }
@@ -179,12 +194,15 @@ fun LoginScreenContent(
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onDone = { onDone = {
if (username.isBlank() || password.isBlank()) if (username.isBlank() || password.isBlank()) {
scope.launch { scope.launch {
snackbarHostState.showSnackbar("Username and Password is required!") snackbarHostState.showSnackbar("Username and Password is required!")
} }
else } else {
onLogin(serverUrl, username, password) onLogin(username, password)
keyboardController?.hide()
focusManager.clearFocus()
}
} }
), ),
value = password, value = password,
@@ -197,12 +215,15 @@ fun LoginScreenContent(
.width(150.dp) .width(150.dp)
.height(45.dp), .height(45.dp),
onClick = { onClick = {
if (username.isBlank() || password.isBlank()) if (username.isBlank() || password.isBlank()) {
scope.launch { scope.launch {
snackbarHostState.showSnackbar("Username and password is required!") snackbarHostState.showSnackbar("Username and password is required!")
} }
else } else {
onLogin(serverUrl.trim(), username.trim(), password.trim()) onLogin(username.trim(), password.trim())
keyboardController?.hide()
focusManager.clearFocus()
}
}, },
enabled = loginState !is LoginUiState.Loading enabled = loginState !is LoginUiState.Loading
) { ) {
@@ -214,7 +235,6 @@ fun LoginScreenContent(
color = Color.Blue, color = Color.Blue,
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} }
} }
} }
@@ -227,7 +247,8 @@ private fun LoginScreenPreview() {
LoginScreenContent( LoginScreenContent(
viewModel = viewModel(), viewModel = viewModel(),
loginState = LoginUiState.Idle, loginState = LoginUiState.Idle,
onLogin = { _, _, _ -> }, serverUrl = "http://localhost:3000",
onLogin = {_, _ -> },
onLoginSuccess = {} onLoginSuccess = {}
) )
} }
@@ -32,7 +32,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.auto_connect import torquevaultkmp.composeapp.generated.resources.auto_connect
@@ -53,29 +55,28 @@ fun SettingScreen(
navController: NavController, navController: NavController,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onThemeToggle: (Boolean) -> Unit, onThemeToggle: (Boolean) -> Unit,
// viewModel: SettingViewModel = hiltViewModel()
) { ) {
// val serverUrl by viewModel.serverUrl.collectAsState() val viewModel = koinViewModel<SettingViewModel>()
// val token by viewModel.refreshToken.collectAsState() val serverUrl by viewModel.serverUrl.collectAsState()
// val autoConnect by viewModel.autoConnect.collectAsState() val token by viewModel.refreshToken.collectAsState()
val serverUrl = "" val autoConnect by viewModel.autoConnect.collectAsState()
val token = ""
val autoConnect = ""
SettingContent( SettingContent(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onThemeToggle = onThemeToggle, onThemeToggle = onThemeToggle,
serverUrl = serverUrl, serverUrl = serverUrl,
onServerUrlChange = { /** viewModel.updateServerUrl(it) */ }, onServerUrlChange = { viewModel.updateServerUrl(it) },
token = token, token = token,
autoConnect = autoConnect == "true", autoConnect = autoConnect,
onTokenChange = { /** viewModel.updateRefreshToken(it) */ }, onTokenChange = { viewModel.updateRefreshToken(it) },
onSaveSettings = { /** viewModel.saveSettings() */ }, onSaveSettings = { viewModel.saveSettings() },
onLogout = { onLogout = {
// viewModel.logout() viewModel.logout()
// navController.navigate(AuthDestination.route) navController.navigate(AuthDestination.route) {
popUpTo(0)
}
}, },
onChangeAutoConnect = { /** viewModel.updateAutoConnect(it) */ } onChangeAutoConnect = { viewModel.updateAutoConnect(it) }
) )
} }
@@ -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<String> = _serverUrl.asStateFlow()
private val _refreshToken = MutableStateFlow(tokenManager.getRefreshToken() ?: "")
val refreshToken: StateFlow<String> = _refreshToken.asStateFlow()
private val _autoConnect = MutableStateFlow(storage.load<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT) ?: false)
val autoConnect: StateFlow<Boolean> = _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)
}
}