Compare commits

...

13 Commits

Author SHA1 Message Date
37d8e8cc74 Refined network configuration and login UI
The login screen now labels the server input as "Server-Domain", and the network provider has been updated to automatically prepend the HTTPS protocol. The database version has been incremented, and a network security configuration has been added to allow user certificates for debugging. Additionally, the encrypted shared preferences storage name was updated.
2026-03-03 16:30:21 +01:00
f3f83b7ca9 Implemented expense tracking and data fetching
The `Expense` entity and its associated API endpoints have been updated, and a new `ExpenseShare` entity with a corresponding DAO has been added.

The `MainViewModel` now includes logic to fetch expenses from the server upon successful session validation. The `DashboardScreen` has been updated to display a list of expenses using a `LazyColumn` and a new `ExpenseItem` component. Additionally, the `APIService` has been expanded to include CRUD operations for expenses and user information.
2026-03-01 16:55:09 +01:00
552f604200 Updated RefreshResponse field names and token handling
The `RefreshResponse` data class has been updated to use `access_token` and `refresh_token` to match the API response. Consequently, `MainViewModel` now uses these updated field names when saving new tokens to storage. Debug print statements for access and refresh tokens have also been removed.
2026-02-27 22:21:13 +01:00
f01357987c Cleaned up AndroidManifest.xml
The manifest has been simplified by removing unused namespace declarations and redundant activity labels. Extraneous whitespace and empty lines were also removed for better readability.
2026-02-27 20:35:32 +01:00
b4229c29c4 Cleaned up unused imports
Removed several unused Compose and utility imports from `MainActivity.kt`, `LoginViewModel.kt`, and `APIService.kt` to improve code cleanliness.
2026-02-27 20:06:49 +01:00
d05d93a0d7 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`.
2026-02-27 20:04:05 +01:00
37e125945b Added a README.md file 2026-02-27 19:34:07 +01:00
463e9b6258 Updated .gitignore
The `.gitignore` file has been reorganized and expanded to include standard exclusions for build artifacts, Kotlin/Java project files, Android Studio metadata, keys/secrets, and OS-generated files.
2026-02-27 19:26:25 +01:00
e99e951232 Updated Login and Git configuration
The `LoginViewModel` now trims whitespace from usernames during the login request and account creation process.

The `.gitignore` file has been updated to exclude `.jks` files and the `/app/release` directory.
2026-02-27 19:23:04 +01:00
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
20 changed files with 618 additions and 219 deletions

43
.gitignore vendored
View File

@@ -1,11 +1,36 @@
# Build artifacts
bin/
gen/
out/
build/
app/build/
# Kotlin / Java
*.iml *.iml
.gradle *.ipr
/local.properties *.iws
/.idea .idea/
.DS_Store .gradle/
/build
/captures
.externalNativeBuild
.cxx
local.properties local.properties
/app/build
# Android Studio
captures/
.externalNativeBuild/
.cxx/
.navigation/
# Keys & Secrets
*.jks
*.keystore
*.p12
google-services.json
# OS generated files
.DS_Store
Thumbs.db
# Log files
*.log
# Bundle/Release
/app/release/

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# ShAp-Planner
ShAp-Planner is a **self-hosted app** for managing finances, tasks, and data within shared households.
The app is fully open source, lightweight, and can run on small devices like Raspberry Pi or older computers.
**[Backend](https://git.miaurizius.de/MiauRizius/shap-planner-backend):** Go
**[Frontend](https://git.miaurizius.de/MiauRizius/shap-planner-android):** Android (Kotlin)
**[License](https://git.miaurizius.de/MiauRizius/shap-planner-android/src/branch/main/LICENSE):** [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)
---
## Installation
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

@@ -1,9 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -12,19 +9,16 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.ShapPlanner"> android:theme="@style/Theme.ShapPlanner">
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ShapPlanner"> android:theme="@style/Theme.ShapPlanner">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View File

@@ -12,13 +12,14 @@ class TokenStorage(context: Context) {
private val prefs = EncryptedSharedPreferences.create( private val prefs = EncryptedSharedPreferences.create(
context, context,
"wg_token_prefs", "wg_token_prefs2",
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) )
fun saveTokens(accountId: String, accessToken: String, refreshToken: String) { fun saveTokens(accountId: String, accessToken: String, refreshToken: String) {
// println("Account ID: ${accountId}\nAToken: ${accessToken}\nRToken: ${refreshToken}")
prefs.edit() prefs.edit()
.putString("access_$accountId", accessToken) .putString("access_$accountId", accessToken)
.putString("refresh_$accountId", refreshToken) .putString("refresh_$accountId", refreshToken)

View File

@@ -2,212 +2,63 @@ 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.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.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import de.miaurizius.shap_planner.TokenStorage
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.UserPreferences import de.miaurizius.shap_planner.UserPreferences
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.room.AppDatabase 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.ui.theme.ShapPlannerTheme
import de.miaurizius.shap_planner.viewmodels.LoginViewModel import de.miaurizius.shap_planner.viewmodels.LoginViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel
import java.util.UUID
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// enableEdgeToEdge()
val prefs = UserPreferences(this) val prefs = UserPreferences(this)
val loginViewModel = LoginViewModel(prefs, applicationContext) val loginViewModel = LoginViewModel(prefs, applicationContext)
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 accountList by mainViewModel.accounts.collectAsState() val accountList by mainViewModel.accounts.collectAsState()
val selectedAccount = mainViewModel.selectedAccount val selectedAccount = mainViewModel.selectedAccount
val showLoginForNewAccount = remember { mutableStateOf(false) }
val expenses by mainViewModel.expenses.collectAsState()
when { BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) {
!isLoggedIn || accountList.isEmpty() -> { showLoginForNewAccount.value = false
LoginScreen { serverUrl, username, password -> loginViewModel.login(serverUrl, username, password, mainViewModel) }
}
selectedAccount != null -> {
DashboardScreen(
account = selectedAccount,
onBack = { mainViewModel.logoutFromAccount() }
)
}
else -> {
AccountSelectionScreen(
accounts = accountList,
onAccountClick = { account ->
mainViewModel.selectAccount(account)
},
onAddAccountClick = {
loginViewModel.logout()
}
)
}
} }
AppContent(
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() },
expenses = expenses,
onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
viewModel = mainViewModel
)
} }
} }
} }
} }
@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) {
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) {
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))
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium),
contentAlignment = Alignment.Center
) {
Text("Hier kommen bald deine WG-Kosten hin 🚀")
}
}
}

View File

@@ -12,12 +12,14 @@ import java.util.UUID
@Entity(tableName = "expenses") @Entity(tableName = "expenses")
data class Expense ( data class Expense (
val id: UUID, val id: UUID,
val amt: Double, val payer_id: UUID,
val desc: String, val amount: Int,
val title: String,
val payerId: UUID, val description: String,
val debtors: List<User> val attachments: List<String>,
) val created_at: Int,
val last_updated_at: Int
)
@Dao @Dao
interface ExpenseDao { interface ExpenseDao {

View File

@@ -0,0 +1,33 @@
package de.miaurizius.shap_planner.entities
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Entity(tableName = "expense_shares")
data class ExpenseShare(
val id: UUID,
val expense_id: UUID,
val user_id: UUID,
val share_cents: Int
)
@Dao
interface ExpenseShareDao {
@Query("SELECT * FROM expense_shares")
fun getAllShares(): Flow<List<ExpenseShare>>
@Query("SELECT * FROM expense_shares WHERE id = :shareId")
fun getShareById(shareId: UUID)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShare(share: ExpenseShare)
@Delete
suspend fun deleteShare(share: ExpenseShare)
}

View File

@@ -13,6 +13,7 @@ import java.util.UUID
data class User ( data class User (
val id: UUID, val id: UUID,
val name: String, val name: String,
val avatar_url: String?
) )
@Dao @Dao

View File

@@ -2,16 +2,34 @@ package de.miaurizius.shap_planner.network
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
data class LoginRequest(val username: String, val password: String) import retrofit2.http.Query
data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?) import java.util.UUID
data class LoginResponse(val access_token: String, val refresh_token: String, val user: LoginUser, val wgName: String)
interface APIService { interface APIService {
// Account
@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>>
// Expenses
@GET("api/expenses")
suspend fun expenseGet(@Header("Authorization") token: String): Response<ExpenseResponse>
@POST("api/expenses")
suspend fun expenseCreate(@Header("Authorization") token: String)
@PUT("api/expenses")
suspend fun expenseUpdate(@Header("Authorization") token: String)
@DELETE("api/expenses")
suspend fun expenseDelete(@Header("Authorization") token: String)
// User
@GET("api/userinfo")
suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID)
} }

View File

@@ -0,0 +1,15 @@
package de.miaurizius.shap_planner.network
import de.miaurizius.shap_planner.entities.Expense
// Login
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)
// Refresh-Tokens
data class RefreshRequest(val refresh_token: String)
data class RefreshResponse(val access_token: String, val refresh_token: String)
// Expenses
data class ExpenseResponse(val expenses: List<Expense>)

View File

@@ -8,7 +8,7 @@ import retrofit2.converter.gson.GsonConverterFactory
object RetrofitProvider { object RetrofitProvider {
fun create(serverUrl: String): APIService { fun create(serverUrl: String): APIService {
val base = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/" val base = if (serverUrl.endsWith("/")) "https://$serverUrl" else "https://$serverUrl/"
val logger = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } val logger = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }

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.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 = 4)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao abstract fun accountDao(): AccountDao
companion object { companion object {

View File

@@ -0,0 +1,61 @@
package de.miaurizius.shap_planner.ui
import androidx.compose.runtime.Composable
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.Expense
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
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@Composable
fun AppContent(
// Login
accountList: List<Account>,
selectedAccount: Account?,
showLoginForNewAccount: Boolean,
onLogin: (String, String, String) -> Unit,
// Expenses
expenses: List<Expense>,
onExpenseClick: (Expense) -> Unit,
// Account
onSelectAccount: (Account) -> Unit,
onLogoutAccount: () -> Unit,
onAddAccountClick: () -> Unit,
onDeleteAccount: () -> Unit,
// Session
sessionState: SessionState,
onValidateSession: () -> Unit,
onSessionInvalid: () -> Unit,
//Important
viewModel: MainViewModel
) {
when {
showLoginForNewAccount -> LoginScreen(onLogin)
accountList.isEmpty() -> LoginScreen(onLogin)
selectedAccount != null -> DashboardScreen(
// Data and regarding Methods
account = selectedAccount,
expenses = expenses,
onExpenseClick = onExpenseClick,
// Default Methods
mainViewModel = viewModel,
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,143 @@
package de.miaurizius.shap_planner.ui.screens
import androidx.activity.compose.BackHandler
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.PaddingValues
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.Expense
import de.miaurizius.shap_planner.network.SessionState
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@Composable
fun DashboardScreen(
// Data and regarding Methods
account: Account,
expenses: List<Expense>,
onExpenseClick: (Expense) -> Unit,
// Default Methods
mainViewModel: MainViewModel,
onBack: () -> Unit,
onDelete: () -> Unit,
sessionState: SessionState,
onValidate: () -> Unit,
onSessionInvalid: () -> Unit) {
LaunchedEffect(Unit) { onValidate() }
mainViewModel.loadExpenses(account)
when (sessionState) {
SessionState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
SessionState.Valid -> {
BackHandler {
onBack()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.statusBarsPadding()
.navigationBarsPadding()
) {
// Header
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))
Text("WG-Kosten", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(expenses) { expense ->
ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) })
}
}
}
}
SessionState.Invalid -> {
LaunchedEffect(Unit) {
onSessionInvalid()
}
}
is SessionState.Error -> {
Text("Server error")
}
}
}
@Composable
fun ExpenseItem(expense: Expense, onClick: () -> Unit) {
Surface(modifier = Modifier
.fillMaxWidth()
.clickable{onClick()},
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = expense.title, style = MaterialTheme.typography.bodyLarge)
Text(text = expense.amount.toString()+"", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
}
}
}

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-Domain") }
)
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")
}
}
}

View File

@@ -13,6 +13,7 @@ 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.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 +29,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()).trim(), password))
} }
if(response.isSuccessful) { if(response.isSuccessful) {
@@ -43,7 +44,7 @@ class LoginViewModel(private val prefs: UserPreferences, private val appContext:
val account = Account( val account = Account(
id = UUID.fromString(body.user.id), id = UUID.fromString(body.user.id),
name = username, name = username.trim(),
wgName = body.wgName, wgName = body.wgName,
avatarUrl = null, avatarUrl = null,
serverUrl = serverUrl, serverUrl = serverUrl,

View File

@@ -5,32 +5,111 @@ 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.entities.Expense
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.MutableStateFlow
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() {
var selectedAccount by mutableStateOf<Account?>(null)
private set
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
private val _expenses = MutableStateFlow<List<Expense>>(emptyList())
val expenses: StateFlow<List<Expense>> = _expenses
fun loadExpenses(account: Account) {
viewModelScope.launch {
try {
val api = RetrofitProvider.create(account.serverUrl)
val accessToken = tokenStorage.getAccess(account.id.toString())
val response = api.expenseGet("Bearer $accessToken")
if (response.isSuccessful) {
_expenses.value = response.body()?.expenses ?: emptyList()
}
} catch (e: Exception) {
_expenses.value = emptyList()
}
}
}
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
}
val pingResponse = api.ping("Bearer $accessToken")
if(pingResponse.isSuccessful) {
sessionState = SessionState.Valid
return@launch
}
if(pingResponse.code() == 401) {
val refreshResponse = api.refresh(RefreshRequest(refreshToken))
if(refreshResponse.isSuccessful) {
val newTokens = refreshResponse.body()!!
tokenStorage.saveTokens(
account.id.toString(),
newTokens.access_token,
newTokens.refresh_token
)
sessionState = SessionState.Valid
// Fetch data
loadExpenses(account)
println("All data fetched")
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) {
var selectedAccount by mutableStateOf<Account?>(null) viewModelScope.launch {
private set accountDao.deleteAccount(account)
tokenStorage.clearTokens(account.id.toString())
selectedAccount = null
}
}
fun selectAccount(account: Account) { fun selectAccount(account: Account) {
selectedAccount = account selectedAccount = account
} }
fun logoutFromAccount() { fun logoutFromAccount() {
selectedAccount = null selectedAccount = null
} }

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>