Compare commits

...

4 Commits

Author SHA1 Message Date
ca1ad57f7e Implemented session validation and token refreshing
Added a `SessionState` sealed class to track the connection status and integrated it into the `DashboardScreen`. The dashboard now triggers a session validation on launch, showing a loading indicator during the check.

The `MainViewModel` has been updated with a `validateSession` function that:
- Pings the server using the stored access token.
- Attempts to refresh the token if a 401 Unauthorized response is received.
- Updates the `TokenStorage` with new tokens upon successful refresh.
- Redirects to logout if both tokens are invalid.

Additionally, the `APIService` was expanded with `ping` and `refresh` endpoints, and the `AppDatabase` version was incremented.
2026-02-27 19:04:28 +01:00
8ad212ee76 Integrated TokenStorage into MainViewModel
MainViewModel now accepts a `TokenStorage` instance. When an account is deleted, its associated tokens are now cleared from storage. MainActivity has been updated to initialize and provide the `TokenStorage` to the view model.
2026-02-27 18:33:34 +01:00
dc9f4121e0 Added account deletion and improved login case-insensitivity
The Dashboard now includes a "Löschen" (Delete) button that allows users to remove the currently selected account via the `MainViewModel`.

The login process has been updated to convert usernames to lowercase before authentication to ensure consistency. Additionally, spacing adjustments were made to the Dashboard UI layout.
2026-02-27 18:28:17 +01:00
d94b3f74de Improved account management and navigation
The app now supports adding a new account without logging out of the current session. A `BackHandler` has been implemented in the `LoginScreen` and `DashboardScreen` to improve navigation flow, and the `LoginScreen` now accepts an optional `onBack` callback.
2026-02-27 18:15:18 +01:00
6 changed files with 188 additions and 37 deletions

View File

@@ -2,6 +2,7 @@ package de.miaurizius.shap_planner.activities
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -23,6 +24,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
@@ -39,8 +41,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.UserPreferences
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.network.SessionState
import de.miaurizius.shap_planner.room.AppDatabase
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme
import de.miaurizius.shap_planner.viewmodels.LoginViewModel
@@ -57,23 +61,48 @@ class MainActivity : ComponentActivity() {
val database = AppDatabase.getDatabase(applicationContext)
val dao = database.accountDao()
val mainViewModel = MainViewModel(dao)
val tokenStorage = TokenStorage(applicationContext)
val mainViewModel = MainViewModel(dao, tokenStorage)
setContent {
ShapPlannerTheme {
val isLoggedIn by loginViewModel.isLoggedIn.collectAsState()
val accountList by mainViewModel.accounts.collectAsState()
val selectedAccount = mainViewModel.selectedAccount
var showLoginForNewAccount by remember { mutableStateOf(false) }
when {
!isLoggedIn || accountList.isEmpty() -> {
LoginScreen { serverUrl, username, password -> loginViewModel.login(serverUrl, username, password, mainViewModel) }
showLoginForNewAccount -> {
LoginScreen(
onLogin = { serverUrl, username, password ->
loginViewModel.login(serverUrl, username, password, mainViewModel)
showLoginForNewAccount = false
},
onBack = {
showLoginForNewAccount = false
}
)
}
accountList.isEmpty() -> {
LoginScreen(
onLogin = { serverUrl, username, password ->
loginViewModel.login(serverUrl, username, password, mainViewModel)
}
)
}
selectedAccount != null -> {
DashboardScreen(
account = selectedAccount,
onBack = { mainViewModel.logoutFromAccount() }
onBack = { mainViewModel.logoutFromAccount() },
onDelete = { mainViewModel.deleteAccount(selectedAccount) },
sessionState = mainViewModel.sessionState,
onValidate = { mainViewModel.validateSession(selectedAccount) },
onSessionInvalid = { mainViewModel.logoutFromAccount() }
)
}
@@ -84,7 +113,7 @@ class MainActivity : ComponentActivity() {
mainViewModel.selectAccount(account)
},
onAddAccountClick = {
loginViewModel.logout()
showLoginForNewAccount = true
}
)
}
@@ -133,7 +162,14 @@ fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) ->
}
@Composable
fun LoginScreen(onLogin: (String, String, String) -> Unit) {
fun LoginScreen(onLogin: (String, String, String) -> Unit, onBack: (() -> Unit)? = null) {
if (onBack != null) {
BackHandler {
onBack()
}
}
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
@@ -177,7 +213,26 @@ fun LoginScreen(onLogin: (String, String, String) -> Unit) {
}
@Composable
fun DashboardScreen(account: Account, onBack: () -> Unit) {
fun DashboardScreen(
account: Account,
onBack: () -> Unit,
onDelete: () -> Unit,
sessionState: SessionState,
onValidate: () -> Unit,
onSessionInvalid: () -> Unit) {
LaunchedEffect(Unit) { onValidate() }
when (sessionState) {
SessionState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
SessionState.Valid -> {
BackHandler {
onBack()
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -199,7 +254,13 @@ fun DashboardScreen(account: Account, onBack: () -> Unit) {
}
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(5.dp))
Button(onClick = onDelete) {
Text("Löschen")
}
Spacer(modifier = Modifier.height(10.dp))
Box(
modifier = Modifier
@@ -211,3 +272,14 @@ fun DashboardScreen(account: Account, onBack: () -> Unit) {
}
}
}
SessionState.Invalid -> {
LaunchedEffect(Unit) {
onSessionInvalid()
}
}
is SessionState.Error -> {
Text("Server error")
}
}
}

View File

@@ -2,16 +2,25 @@ package de.miaurizius.shap_planner.network
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST
data class LoginRequest(val username: String, val password: String)
data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?)
data class LoginResponse(val access_token: String, val refresh_token: String, val user: LoginUser, val wgName: String)
data class RefreshRequest(val refreshToken: String)
data class RefreshResponse(val accessToken: String, val refreshToken: String)
interface APIService {
@POST("api/login")
suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
@POST("api/refresh")
suspend fun refresh(@Body req: Map<String, String>): Response<Map<String, String>>
suspend fun refresh(@Body req: RefreshRequest): Response<RefreshResponse>
@GET("api/ping")
suspend fun ping(@Header("Authorization") token: String): Response<Map<String, String>>
}

View File

@@ -0,0 +1,8 @@
package de.miaurizius.shap_planner.network
sealed class SessionState {
object Loading : SessionState()
object Valid : SessionState()
object Invalid : SessionState()
data class Error(val message: String) : SessionState()
}

View File

@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao
@Database(entities = [Account::class], version = 2)
@Database(entities = [Account::class], version = 3)
abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
companion object {

View File

@@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import java.util.Locale.getDefault
import java.util.UUID
class LoginViewModel(private val prefs: UserPreferences, private val appContext: Context) : ViewModel() {
@@ -28,7 +30,7 @@ class LoginViewModel(private val prefs: UserPreferences, private val appContext:
try {
val response = withContext(Dispatchers.IO) {
api.login(LoginRequest(username, password))
api.login(LoginRequest(username.lowercase(getDefault()), password))
}
if(response.isSuccessful) {

View File

@@ -5,25 +5,85 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao
import de.miaurizius.shap_planner.network.RefreshRequest
import de.miaurizius.shap_planner.network.RetrofitProvider
import de.miaurizius.shap_planner.network.SessionState
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.collections.emptyList
class MainViewModel(private val accountDao: AccountDao) : ViewModel() {
class MainViewModel(private val accountDao: AccountDao, private val tokenStorage: TokenStorage) : ViewModel() {
val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
private set
fun validateSession(account: Account) {
viewModelScope.launch {
sessionState = SessionState.Loading
val api = RetrofitProvider.create(account.serverUrl)
val accessToken = tokenStorage.getAccess(account.id.toString())
val refreshToken = tokenStorage.getRefresh(account.id.toString())
if(accessToken == null || refreshToken == null) {
sessionState = SessionState.Invalid
return@launch
}
println("Testing with AT $accessToken")
val pingResponse = api.ping("Bearer $accessToken")
if(pingResponse.isSuccessful) {
sessionState = SessionState.Valid
return@launch
}
if(pingResponse.code() == 401) {
println("Testing with RT $refreshToken")
val refreshResponse = api.refresh(RefreshRequest(refreshToken))
if(refreshResponse.isSuccessful) {
val newTokens = refreshResponse.body()!!
tokenStorage.saveTokens(
account.id.toString(),
newTokens.accessToken,
newTokens.accessToken
)
sessionState = SessionState.Valid
return@launch
} else {
sessionState = SessionState.Invalid
return@launch
}
}
sessionState = SessionState.Error("Server error")
}
}
fun addAccount(account: Account) {
viewModelScope.launch {
accountDao.insertAccount(account)
}
}
fun deleteAccount(account: Account) {
viewModelScope.launch {
accountDao.deleteAccount(account)
tokenStorage.clearTokens(account.id.toString())
selectedAccount = null
}
}
var selectedAccount by mutableStateOf<Account?>(null)
private set