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)
+ }
+}