Compare commits
4 Commits
aa10114767
...
ca1ad57f7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
ca1ad57f7e
|
|||
|
8ad212ee76
|
|||
|
dc9f4121e0
|
|||
|
d94b3f74de
|
@@ -2,6 +2,7 @@ package de.miaurizius.shap_planner.activities
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -23,6 +24,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.miaurizius.shap_planner.TokenStorage
|
||||||
import de.miaurizius.shap_planner.UserPreferences
|
import de.miaurizius.shap_planner.UserPreferences
|
||||||
import de.miaurizius.shap_planner.entities.Account
|
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.room.AppDatabase
|
||||||
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme
|
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme
|
||||||
import de.miaurizius.shap_planner.viewmodels.LoginViewModel
|
import de.miaurizius.shap_planner.viewmodels.LoginViewModel
|
||||||
@@ -57,23 +61,48 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
val database = AppDatabase.getDatabase(applicationContext)
|
val database = AppDatabase.getDatabase(applicationContext)
|
||||||
val dao = database.accountDao()
|
val dao = database.accountDao()
|
||||||
val mainViewModel = MainViewModel(dao)
|
|
||||||
|
val tokenStorage = TokenStorage(applicationContext)
|
||||||
|
|
||||||
|
val mainViewModel = MainViewModel(dao, tokenStorage)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ShapPlannerTheme {
|
ShapPlannerTheme {
|
||||||
val isLoggedIn by loginViewModel.isLoggedIn.collectAsState()
|
val isLoggedIn by loginViewModel.isLoggedIn.collectAsState()
|
||||||
val accountList by mainViewModel.accounts.collectAsState()
|
val accountList by mainViewModel.accounts.collectAsState()
|
||||||
val selectedAccount = mainViewModel.selectedAccount
|
val selectedAccount = mainViewModel.selectedAccount
|
||||||
|
var showLoginForNewAccount by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
when {
|
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 -> {
|
selectedAccount != null -> {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
account = selectedAccount,
|
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)
|
mainViewModel.selectAccount(account)
|
||||||
},
|
},
|
||||||
onAddAccountClick = {
|
onAddAccountClick = {
|
||||||
loginViewModel.logout()
|
showLoginForNewAccount = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -133,7 +162,14 @@ fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 serverUrl by remember { mutableStateOf("") }
|
||||||
var username by remember { mutableStateOf("") }
|
var username by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
@@ -177,37 +213,73 @@ fun LoginScreen(onLogin: (String, String, String) -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(account: Account, onBack: () -> Unit) {
|
fun DashboardScreen(
|
||||||
Column(
|
account: Account,
|
||||||
modifier = Modifier
|
onBack: () -> Unit,
|
||||||
.fillMaxSize()
|
onDelete: () -> Unit,
|
||||||
.padding(16.dp)
|
sessionState: SessionState,
|
||||||
.statusBarsPadding()
|
onValidate: () -> Unit,
|
||||||
.navigationBarsPadding()
|
onSessionInvalid: () -> Unit) {
|
||||||
) {
|
|
||||||
Row(
|
LaunchedEffect(Unit) { onValidate() }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
when (sessionState) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
SessionState.Loading -> {
|
||||||
) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column {
|
CircularProgressIndicator()
|
||||||
Text(text = "Hallo, ${account.name}!", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
Text(text = "WG: ${account.wgName}", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
|
|
||||||
}
|
|
||||||
Button(onClick = onBack) {
|
|
||||||
Text("Wechseln")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SessionState.Valid -> {
|
||||||
|
BackHandler {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(text = "Hallo, ${account.name}!", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Text(text = "WG: ${account.wgName}", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
|
||||||
|
}
|
||||||
|
Button(onClick = onBack) {
|
||||||
|
Text("Wechseln")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(5.dp))
|
||||||
|
|
||||||
Box(
|
Button(onClick = onDelete) {
|
||||||
modifier = Modifier
|
Text("Löschen")
|
||||||
.fillMaxSize()
|
}
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium),
|
|
||||||
contentAlignment = Alignment.Center
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
) {
|
|
||||||
Text("Hier kommen bald deine WG-Kosten hin 🚀")
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Hier kommen bald deine WG-Kosten hin 🚀")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionState.Invalid -> {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
onSessionInvalid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SessionState.Error -> {
|
||||||
|
Text("Server error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,16 +2,25 @@ package de.miaurizius.shap_planner.network
|
|||||||
|
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.Headers
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
|
||||||
data class LoginRequest(val username: String, val password: String)
|
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 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 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 {
|
interface APIService {
|
||||||
@POST("api/login")
|
@POST("api/login")
|
||||||
suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
|
suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
|
||||||
|
|
||||||
@POST("api/refresh")
|
@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>>
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
|
|||||||
import de.miaurizius.shap_planner.entities.Account
|
import de.miaurizius.shap_planner.entities.Account
|
||||||
import de.miaurizius.shap_planner.entities.AccountDao
|
import de.miaurizius.shap_planner.entities.AccountDao
|
||||||
|
|
||||||
@Database(entities = [Account::class], version = 2)
|
@Database(entities = [Account::class], version = 3)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun accountDao(): AccountDao
|
abstract fun accountDao(): AccountDao
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.Locale.getDefault
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class LoginViewModel(private val prefs: UserPreferences, private val appContext: Context) : ViewModel() {
|
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 {
|
try {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
api.login(LoginRequest(username, password))
|
api.login(LoginRequest(username.lowercase(getDefault()), password))
|
||||||
}
|
}
|
||||||
|
|
||||||
if(response.isSuccessful) {
|
if(response.isSuccessful) {
|
||||||
|
|||||||
@@ -5,25 +5,85 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.miaurizius.shap_planner.TokenStorage
|
||||||
import de.miaurizius.shap_planner.entities.Account
|
import de.miaurizius.shap_planner.entities.Account
|
||||||
import de.miaurizius.shap_planner.entities.AccountDao
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.collections.emptyList
|
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()
|
val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.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) {
|
fun addAccount(account: Account) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
accountDao.insertAccount(account)
|
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)
|
var selectedAccount by mutableStateOf<Account?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user