Handle logic login, save tokens, change theme, change server url
This commit is contained in:
@@ -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>
|
||||
|
||||
+3
-1
@@ -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}") {
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+2
@@ -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"
|
||||
}
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+11
-1
@@ -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) {
|
||||
|
||||
+39
-18
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+15
-14
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user