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="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_password">Password</string>
<string name="btn_login">Login</string>
@@ -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}") {
@@ -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<WorkOrderDto> {
return client.get("$baseUrl${Endpoints.WORK_ORDERS}") {
parameter("page", page)
@@ -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"
}
@@ -4,6 +4,9 @@ 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)
return accessToken
@@ -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
@@ -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> { AuthApi(get(named("authClient")), get(), get()) }
single<AuthApi> { 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)
}
@@ -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>(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) {
@@ -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<AuthViewModel>()
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 = {}
)
}
@@ -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<SettingViewModel>()
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) }
)
}
@@ -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)
}
}