Refactored UI components and extracted screens into separate files.

The UI logic from `MainActivity.kt` has been moved into a new `AppContent` composable, and individual screens (`LoginScreen`, `DashboardScreen`, `AccountSelectionScreen`) have been extracted to their own files within the `ui.screens` package.

Additional changes:
- Updated `MainActivity` to use the new `AppContent` structure and handled back navigation for new account logins.
- Fixed a field name mismatch in `RefreshRequest` within `APIService.kt`, changing `refreshToken` to `refresh_token`.
- Minor formatting update in `README.md`.
This commit is contained in:
2026-02-27 20:04:05 +01:00
parent 37e125945b
commit d05d93a0d7
7 changed files with 317 additions and 211 deletions

View File

@@ -13,6 +13,8 @@ The app is fully open source, lightweight, and can run on small devices like Ras
You can either build the app from source or download the apk _(will be available soon)_
---
## License
This work is marked <a href="https://creativecommons.org/publicdomain/zero/1.0/">CC0 1.0</a>

View File

@@ -46,6 +46,7 @@ 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.AppContent
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme
import de.miaurizius.shap_planner.viewmodels.LoginViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@@ -54,16 +55,12 @@ import java.util.UUID
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// enableEdgeToEdge()
val prefs = UserPreferences(this)
val loginViewModel = LoginViewModel(prefs, applicationContext)
val database = AppDatabase.getDatabase(applicationContext)
val dao = database.accountDao()
val tokenStorage = TokenStorage(applicationContext)
val mainViewModel = MainViewModel(dao, tokenStorage)
setContent {
@@ -71,215 +68,30 @@ class MainActivity : ComponentActivity() {
val isLoggedIn by loginViewModel.isLoggedIn.collectAsState()
val accountList by mainViewModel.accounts.collectAsState()
val selectedAccount = mainViewModel.selectedAccount
var showLoginForNewAccount by remember { mutableStateOf(false) }
val showLoginForNewAccount = remember { mutableStateOf(false) }
when {
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() },
onDelete = { mainViewModel.deleteAccount(selectedAccount) },
sessionState = mainViewModel.sessionState,
onValidate = { mainViewModel.validateSession(selectedAccount) },
onSessionInvalid = { mainViewModel.logoutFromAccount() }
)
}
else -> {
AccountSelectionScreen(
accounts = accountList,
onAccountClick = { account ->
mainViewModel.selectAccount(account)
},
onAddAccountClick = {
showLoginForNewAccount = true
}
)
}
BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) {
showLoginForNewAccount.value = false
}
AppContent(
isLoggedIn = isLoggedIn,
accountList = accountList,
selectedAccount = selectedAccount,
showLoginForNewAccount = showLoginForNewAccount.value,
onLogin = { server, user, pass ->
loginViewModel.login(server, user, pass, mainViewModel)
showLoginForNewAccount.value = false
},
onSelectAccount = { mainViewModel.selectAccount(it) },
onLogoutAccount = { mainViewModel.logoutFromAccount() },
onAddAccountClick = { showLoginForNewAccount.value = true },
onDeleteAccount = { mainViewModel.deleteAccount(selectedAccount!!) },
sessionState = mainViewModel.sessionState,
onValidateSession = { mainViewModel.validateSession(selectedAccount!!) },
onSessionInvalid = { mainViewModel.logoutFromAccount() }
)
}
}
}
}
@Composable
fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) -> Unit, onAddAccountClick: () -> Unit) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.statusBarsPadding()
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text("Wähle einen Account", style = MaterialTheme.typography.headlineSmall)
}
items(accounts) { account ->
Card(modifier = Modifier.fillMaxWidth().clickable{ onAccountClick(account) }) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(40.dp).background(Color.Gray, shape = CircleShape))
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(text = account.name, fontWeight = FontWeight.Bold)
Text(text = account.wgName, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onAddAccountClick,
modifier = Modifier.fillMaxWidth()
) {
Text("Anderen Account hinzufügen")
}
}
}
}
@Composable
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("") }
Column(modifier = Modifier.padding(16.dp).statusBarsPadding().navigationBarsPadding()) {
Text("Bitte anmelden")
Spacer(modifier = Modifier.height(8.dp))
//Home-Server
TextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server-URL") }
)
Spacer(modifier = Modifier.height(8.dp))
//Username
TextField(
value = username,
onValueChange = { username = it },
label = { Text("Nutzername") }
)
Spacer(modifier = Modifier.height(8.dp))
//Password
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") }
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { if(serverUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()) onLogin(
serverUrl,
username,
password
) }) {
Text("Login")
}
}
}
@Composable
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()
.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(5.dp))
Button(onClick = onDelete) {
Text("Löschen")
}
Spacer(modifier = Modifier.height(10.dp))
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")
}
}
}

View File

@@ -11,7 +11,7 @@ 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 RefreshRequest(val refresh_token: String)
data class RefreshResponse(val accessToken: String, val refreshToken: String)
interface APIService {

View File

@@ -0,0 +1,42 @@
package de.miaurizius.shap_planner.ui
import androidx.compose.runtime.Composable
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.network.SessionState
import de.miaurizius.shap_planner.ui.screens.AccountSelectionScreen
import de.miaurizius.shap_planner.ui.screens.DashboardScreen
import de.miaurizius.shap_planner.ui.screens.LoginScreen
@Composable
fun AppContent(
isLoggedIn: Boolean,
accountList: List<Account>,
selectedAccount: Account?,
showLoginForNewAccount: Boolean,
onLogin: (String, String, String) -> Unit,
onSelectAccount: (Account) -> Unit,
onLogoutAccount: () -> Unit,
onAddAccountClick: () -> Unit,
onDeleteAccount: () -> Unit,
sessionState: SessionState,
onValidateSession: () -> Unit,
onSessionInvalid: () -> Unit
) {
when {
showLoginForNewAccount -> LoginScreen(onLogin)
accountList.isEmpty() -> LoginScreen(onLogin)
selectedAccount != null -> DashboardScreen(
account = selectedAccount,
onBack = onLogoutAccount,
onDelete = onDeleteAccount,
sessionState = sessionState,
onValidate = onValidateSession,
onSessionInvalid = onSessionInvalid
)
else -> AccountSelectionScreen(
accounts = accountList,
onAccountClick = onSelectAccount,
onAddAccountClick = onAddAccountClick
)
}
}

View File

@@ -0,0 +1,69 @@
package de.miaurizius.shap_planner.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account
@Composable
fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) -> Unit, onAddAccountClick: () -> Unit) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.statusBarsPadding()
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text("Wähle einen Account", style = MaterialTheme.typography.headlineSmall)
}
items(accounts) { account ->
Card(modifier = Modifier.fillMaxWidth().clickable{ onAccountClick(account) }) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(40.dp).background(Color.Gray, shape = CircleShape))
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(text = account.name, fontWeight = FontWeight.Bold)
Text(text = account.wgName, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onAddAccountClick,
modifier = Modifier.fillMaxWidth()
) {
Text("Anderen Account hinzufügen")
}
}
}
}

View File

@@ -0,0 +1,111 @@
package de.miaurizius.shap_planner.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.network.SessionState
@Composable
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()
.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(5.dp))
Button(onClick = onDelete) {
Text("Löschen")
}
Spacer(modifier = Modifier.height(10.dp))
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")
}
}
}

View File

@@ -0,0 +1,70 @@
package de.miaurizius.shap_planner.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
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("") }
Column(modifier = Modifier.padding(16.dp).statusBarsPadding().navigationBarsPadding()) {
Text("Bitte anmelden")
Spacer(modifier = Modifier.height(8.dp))
//Home-Server
TextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server-URL") }
)
Spacer(modifier = Modifier.height(8.dp))
//Username
TextField(
value = username,
onValueChange = { username = it },
label = { Text("Nutzername") }
)
Spacer(modifier = Modifier.height(8.dp))
//Password
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") }
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { if(serverUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()) onLogin(
serverUrl,
username,
password
) }) {
Text("Login")
}
}
}