Compare commits

...

27 Commits

Author SHA1 Message Date
463e1a013f Improved UI and enhanced expense creation
Updated the UI across all screens (Login, Dashboard, Account Selection, Expense Details, and Creation) with a more modern, card-based design and improved layouts.

Expense creation now includes:
- Real-time split validation to ensure shares match the total amount.
- An "Equal Split" shortcut for quick allocation.
- Support for optional descriptions and multiple file attachments.
- Strict save validation.

The `MainActivity` now supports edge-to-edge display, and the `AccountDao` includes a new query to fetch accounts by ID. Added `material-icons-extended` dependency for enhanced iconography.
2026-03-04 16:38:40 +01:00
69e0344261 Implemented expense creation functionality.
A new `ExpenseCreationScreen` and `ExpenseCreationViewModel` have been added to allow users to create new expenses. This includes fields for the title, amount, and a participant selection using filter chips.

Key changes:
- Added `ExpenseCreationViewModel` to handle user loading and persistent storage of expenses and shares.
- Created `ExpenseCreationScreen` UI with validation for the "Save" button.
- Updated `DashboardScreen` to include a Floating Action Button (FAB) for navigating to the expense creation flow.
- Added `expenseCreate` API endpoint and related data types (`ExpenseCreationRequest`, `ExpenseCreationResponse`).
- Integrated `ExpenseCreationViewModel` into `MainActivity` and `AppContent`.
2026-03-04 14:55:31 +01:00
25d1038c9d Updated UI text to English and improved currency formatting
The UI strings in `ExpenseDetailScreen`, `DashboardScreen`, and `AccountSelectionScreen` have been translated from German to English.

Additionally, currency display logic has been updated to use `String.format("%.2f€", ...)` for consistent two-decimal formatting across the `ExpenseDetailScreen` and `DashboardScreen`.
2026-03-04 14:22:21 +01:00
ea6349c85c Added Expense Detail screen and functionality
The new `ExpenseDetailScreen` displays expense information, including title, amount, and a breakdown of cost shares among users.

Key changes:
- Created `ExpenseDetailViewModel` to manage loading and combining expense shares with user information.
- Added `ExpenseDetailScreen` UI with a list of shares and a back navigation option.
- Updated `MainActivity` and `AppContent` to handle navigation to the expense detail view.
- Extended `ExpenseShareDao` and `ExpenseShareRepository` to support fetching shares by expense ID.
- Updated `APIService` and `UserRepository` to simplify user information retrieval and support querying shares by different ID types.
- Added `@OptIn(ExperimentalMaterial3Api::class)` to several UI components to support Material 3 features.
2026-03-04 14:16:45 +01:00
4104930ea5 Added UserRepository and ExpenseShareRepository
Implemented `UserRepository` and `ExpenseShareRepository` to handle data fetching with a caching strategy (local DAO + remote API).

Specific changes include:
- Added `getUserById` to `UserDao` and updated `getShareById` in `ExpenseShareDao` to support nullable returns.
- Updated `APIService` and `ComDataTypes` to include endpoints and data models for User info, Expense Shares, and pluralized Expense responses.
- Refactored `ExpenseRepository` to use the updated API naming conventions and removed debug print statements.
2026-03-04 12:28:12 +01:00
3d789c0352 Implemented a repository-based data layer for expenses and updated Room entities.
The `ExpenseRepository` was introduced to handle data fetching with a caching strategy, utilizing a new `Resource` sealed class to represent Loading, Success, and Error states.

Key changes include:
- Added `ExpenseRepository` to manage data flow between the local database and remote API.
- Updated `MainViewModel` to use the repository and expose expenses via a `StateFlow<Resource<List<Expense>>>`.
- Enhanced Room entities (`Expense`, `ExpenseShare`, `User`) with `@PrimaryKey` annotations and added a `Converters` class to handle `List<String>` types.
- Expanded `AppDatabase` to include DAOs for `Expense`, `ExpenseShare`, and `User`.
- Updated `DashboardScreen` to reactively display loading indicators, error messages, and cached/remote expense data.
2026-03-04 11:56:57 +01:00
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
aa10114767 Added LICENSE and updated gitignore
The CC0 1.0 Universal license has been added to the project. The `.gitignore` configuration was consolidated by moving the `/app/build` exclusion to the root `.gitignore` and removing the redundant `app/.gitignore` file.
2026-02-27 17:42:49 +01:00
465a699b30 Implemented network-based authentication and secure token storage
- Replaced mock login logic in `LoginViewModel` with actual API calls using Retrofit.
- Integrated `EncryptedSharedPreferences` via a new `TokenStorage` class to securely persist access and refresh tokens.
- Added `RetrofitProvider` and `APIService` to handle network requests and JSON serialization.
- Updated `Account` entity and database schema (version 2) to include user roles, enabling destructive migration for development.
- Added necessary internet permissions and allowed cleartext traffic in `AndroidManifest.xml`.
- Included dependencies for Retrofit, OkHttp logging, and AndroidX Security.
2026-02-27 17:33:34 +01:00
13d0df0864 feat: Add User and Expense entities with DAOs
This commit introduces the `User` and `Expense` data classes as Room entities. Each entity is accompanied by its corresponding Data Access Object (DAO) with methods for `getAll`, `insert`, and `delete` operations.

Additionally, unused comments were removed from `MainViewModel` and an unnecessary preview was removed from `MainActivity`.
2026-02-21 22:42:13 +01:00
9e0101642a Extended Login and UI</div>
The login process has been expanded to include fields for Server URL, Username, and Password, replacing the previous User ID-only login.

The UI now utilizes `navigationBarsPadding` to prevent overlap with system navigation elements on the Login, Account Selection, and generic screens. The `Account` entity has been updated to include a `serverUrl`.
2026-02-21 00:20:40 +01:00
885d95991e Edited Name and icon 2026-02-20 22:49:28 +01:00
a3ec3f16e1 Added Room 2026-02-20 22:23:22 +01:00
89c8a4b985 Started Login Process 2026-02-20 21:30:57 +01:00
534f844196 Added first datatypes 2026-02-20 21:09:43 +01:00
50 changed files with 2353 additions and 262 deletions

42
.gitignore vendored
View File

@@ -1,10 +1,36 @@
# Build artifacts
bin/
gen/
out/
build/
app/build/
# Kotlin / Java
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
*.ipr
*.iws
.idea/
.gradle/
local.properties
# 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/

121
LICENSE Normal file
View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

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>

1
app/.gitignore vendored
View File

@@ -1 +0,0 @@
/build

View File

@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") version "2.3.4"// apply false
}
android {
@@ -40,6 +41,8 @@ android {
}
dependencies {
//Android Studio Auto-Gen
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -55,4 +58,20 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
//Manually added
implementation("androidx.datastore:datastore-preferences:1.2.0")
implementation("androidx.compose.material:material-icons-extended:1.6.0")
val room_version = "2.8.4"
implementation("androidx.room:room-runtime:$room_version")
ksp("androidx.room:room-compiler:$room_version")
// Retrofit + Gson
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp logging (debug)
implementation("com.squareup.okhttp3:logging-interceptor:4.9.3")
// AndroidX Security (EncryptedSharedPreferences)
implementation("androidx.security:security-crypto:1.1.0-alpha03")
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,47 +0,0 @@
package de.miaurizius.shap_planner
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShapPlannerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ShapPlannerTheme {
Greeting("Android")
}
}

View File

@@ -0,0 +1,40 @@
package de.miaurizius.shap_planner
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class TokenStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"wg_token_prefs2",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveTokens(accountId: String, accessToken: String, refreshToken: String) {
// println("Account ID: ${accountId}\nAToken: ${accessToken}\nRToken: ${refreshToken}")
prefs.edit()
.putString("access_$accountId", accessToken)
.putString("refresh_$accountId", refreshToken)
.apply()
}
fun getAccess(accountId: String): String? = prefs.getString("access_$accountId", null)
fun getRefresh(accountId: String): String? = prefs.getString("refresh_$accountId", null)
fun clearTokens(accountId: String) {
prefs.edit()
.remove("access_$accountId")
.remove("refresh_$accountId")
.apply()
}
}

View File

@@ -0,0 +1,39 @@
package de.miaurizius.shap_planner
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
val Context.dataStore by preferencesDataStore(name = "user_prefs")
object UserPreferencesKeys {
val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
val LAST_USER_ID = stringPreferencesKey("last_user_id")
}
class UserPreferences(private val context: Context) {
//Stave status
suspend fun saveLogin(userId: String) {
context.dataStore.edit { prefs ->
prefs[UserPreferencesKeys.IS_LOGGED_IN] = true
prefs[UserPreferencesKeys.LAST_USER_ID] = userId;
}
}
//Logout
suspend fun clearLogin() {
context.dataStore.edit { prefs ->
prefs[UserPreferencesKeys.IS_LOGGED_IN] = false
prefs[UserPreferencesKeys.LAST_USER_ID] = ""
}
}
//Get state
val isLoggedInFlow: Flow<Boolean> = context.dataStore.data.map { prefs -> prefs[UserPreferencesKeys.IS_LOGGED_IN] ?: false }
val lastUserLoginFlow: Flow<String?> = context.dataStore.data.map { prefs -> prefs[UserPreferencesKeys.LAST_USER_ID] }
}

View File

@@ -0,0 +1,86 @@
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.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.UserPreferences
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.ExpenseCreationViewModel
import de.miaurizius.shap_planner.viewmodels.ExpenseDetailViewModel
import de.miaurizius.shap_planner.viewmodels.LoginViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val prefs = UserPreferences(this)
val loginViewModel = LoginViewModel(prefs, applicationContext)
val database = AppDatabase.getDatabase(applicationContext)
val accountDao = database.accountDao()
val expenseDao = database.expenseDao()
val tokenStorage = TokenStorage(applicationContext)
val mainViewModel = MainViewModel(
accountDao,
expenseDao,
tokenStorage
)
val detailViewModel = ExpenseDetailViewModel(
database.expenseDao(),
database.expenseShareDao(),
database.userDao(),
tokenStorage
)
val creationViewModel = ExpenseCreationViewModel(
database.userDao(),
database.expenseDao(),
database.expenseShareDao(),
tokenStorage
)
setContent {
ShapPlannerTheme {
val accountList by mainViewModel.accounts.collectAsState()
val selectedAccount = mainViewModel.selectedAccount
val showLoginForNewAccount = remember { mutableStateOf(false) }
BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) {
showLoginForNewAccount.value = false
}
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() },
onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
viewModel = mainViewModel,
detailViewModel = detailViewModel,
creationViewModel = creationViewModel,
)
}
}
}
}

View File

@@ -0,0 +1,36 @@
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.PrimaryKey
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Entity(tableName = "accounts")
data class Account (
@PrimaryKey val id: UUID,
val name: String,
val wgName: String,
val avatarUrl: String? = null,
val serverUrl: String,
val role: String,
)
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts")
fun getAllAccounts(): Flow<List<Account>>
@Query("SELECT * FROM accounts WHERE id = :userId")
fun getAccountById(userId: UUID): Flow<Account?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAccount(account: Account)
@Delete
suspend fun deleteAccount(account: Account)
}

View File

@@ -0,0 +1,35 @@
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.PrimaryKey
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Entity(tableName = "expenses")
data class Expense (
@PrimaryKey val id: UUID,
val payer_id: UUID,
val amount: Int,
val title: String,
val description: String,
val attachments: List<String>?,
val created_at: Int,
val last_updated_at: Int
)
@Dao
interface ExpenseDao {
@Query("SELECT * FROM expenses")
fun getAllExpenses(): Flow<List<Expense>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertExpense(expense: Expense)
@Delete
suspend fun deleteExpense(expense: Expense)
}

View File

@@ -0,0 +1,37 @@
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.PrimaryKey
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Entity(tableName = "expense_shares")
data class ExpenseShare(
@PrimaryKey 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): Flow<ExpenseShare?>
@Query("SELECT * FROM expense_shares WHERE expense_id = :expense_id")
fun getSharesByExpense(expense_id: UUID): Flow<List<ExpenseShare>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShare(share: ExpenseShare)
@Delete
suspend fun deleteShare(share: ExpenseShare)
}

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.PrimaryKey
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Entity(tableName = "users")
data class User (
@PrimaryKey val id: UUID,
val name: String,
val avatar_url: String?
)
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>
@Query("SELECT * FROM users WHERE id = :userId")
fun getUserById(userId: UUID): Flow<User?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
@Delete
suspend fun deleteUser(user: User)
}

View File

@@ -0,0 +1,54 @@
package de.miaurizius.shap_planner.network
import com.google.gson.annotations.SerializedName
import de.miaurizius.shap_planner.entities.User
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Query
import java.util.UUID
interface APIService {
// Account
@POST("api/login")
suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
@POST("api/refresh")
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 expensesGet(@Header("Authorization") token: String): Response<ExpensesResponse>
@POST("api/expenses")
suspend fun expenseCreate(@Header("Authorization") token: String, @Body req: ExpenseCreationRequest): Response<ExpenseCreationResponse>
@PUT("api/expenses")
suspend fun expenseUpdate(@Header("Authorization") token: String)
@DELETE("api/expenses")
suspend fun expenseDelete(@Header("Authorization") token: String)
// Shares
@GET("api/shares")
suspend fun sharesGet(@Header("Authorization") token: String): Response<ExpenseSharesResponse>
@GET("api/shares")
suspend fun shareGet(@Header("Authorization") token: String, @Query("id") shareId: UUID): Response<ExpenseShareResponse>
@GET("api/shares")
suspend fun shareGet(@Header("Authorization") token: String, @Query("id") expenseId: UUID, @Query("idType") idType: IDType): Response<ExpenseSharesResponse>
// User
@GET("api/userinfo")
suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID): Response<User>
}
enum class IDType {
@SerializedName("share")
Share,
@SerializedName("expense")
Expense,
@SerializedName("user")
User,
}

View File

@@ -0,0 +1,22 @@
package de.miaurizius.shap_planner.network
import de.miaurizius.shap_planner.entities.Expense
import de.miaurizius.shap_planner.entities.ExpenseShare
// 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 ExpensesResponse(val expenses: List<Expense>)
data class ExpenseCreationRequest(val expense: Expense, val shares: List<ExpenseShare>)
data class ExpenseCreationResponse(val expense: Expense, val shares: List<ExpenseShare>)
// ExpenseShares
data class ExpenseSharesResponse(val shares: List<ExpenseShare>)
data class ExpenseShareResponse(val share: ExpenseShare)

View File

@@ -0,0 +1,28 @@
package de.miaurizius.shap_planner.network
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitProvider {
fun create(serverUrl: String): APIService {
val base = if (serverUrl.endsWith("/")) "https://$serverUrl" else "https://$serverUrl/"
val logger = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
val client = OkHttpClient.Builder()
.addInterceptor(logger)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(base)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
return retrofit.create(APIService::class.java)
}
}

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

@@ -0,0 +1,33 @@
package de.miaurizius.shap_planner.repository
import de.miaurizius.shap_planner.entities.Expense
import de.miaurizius.shap_planner.entities.ExpenseDao
import de.miaurizius.shap_planner.network.APIService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
class ExpenseRepository(
private val dao: ExpenseDao,
private val api: APIService
) {
fun getExpenses(token: String, forceRefresh: Boolean = false): Flow<Resource<List<Expense>>> = flow {
val cachedExpense = dao.getAllExpenses().first()
emit(Resource.Loading(cachedExpense))
if(cachedExpense.isEmpty() || forceRefresh) {
try {
val response = api.expensesGet("Bearer $token")
if(response.isSuccessful) {
val remoteExpense = response.body()?.expenses ?: emptyList()
remoteExpense.forEach {
dao.insertExpense(it)
}
}
} catch(e: Exception) {
emit(Resource.Error("Network Error: ${e.localizedMessage}", cachedExpense))
}
}
dao.getAllExpenses().collect { emit(Resource.Success(it)) }
}
}

View File

@@ -0,0 +1,72 @@
package de.miaurizius.shap_planner.repository
import de.miaurizius.shap_planner.entities.ExpenseShare
import de.miaurizius.shap_planner.entities.ExpenseShareDao
import de.miaurizius.shap_planner.network.APIService
import de.miaurizius.shap_planner.network.IDType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import java.util.UUID
class ExpenseShareRepository(
private val dao: ExpenseShareDao,
private val api: APIService
) {
fun getShares(token: String, forceRefresh: Boolean = false): Flow<Resource<List<ExpenseShare>>> = flow {
val cachedData = dao.getAllShares().first()
emit(Resource.Loading(cachedData))
if(cachedData.isEmpty() || forceRefresh) {
try {
val response = api.sharesGet("Bearer $token")
if(response.isSuccessful) {
val remoteShare = response.body()?.shares ?: emptyList()
remoteShare.forEach {
dao.insertShare(it)
}
}
} catch(e: Exception) {
emit(Resource.Error("Network Error: ${e.localizedMessage}", cachedData))
}
}
dao.getAllShares().collect { emit(Resource.Success(it)) }
}
fun getShareById(token: String, shareId: UUID, forceRefresh: Boolean = false): Flow<Resource<ExpenseShare>> = flow {
val cached = dao.getShareById(shareId).first()
emit(Resource.Loading(cached))
if(cached == null || forceRefresh) {
try {
val response = api.shareGet("Bearer $token", shareId)
if(response.isSuccessful) {
response.body()?.share?.let { remoteShare -> dao.insertShare(remoteShare) }
}
} catch(e: Exception) {
emit(Resource.Error("Network-Error: ${e.localizedMessage}", cached))
}
}
dao.getShareById(shareId).collect { share ->
if(share != null) emit(Resource.Success(share))
else emit(Resource.Error("Share nicht gefunden", null))
}
}
fun getSharesByExpenseId(token: String, expenseId: UUID, forceRefresh: Boolean = false): Flow<Resource<List<ExpenseShare>>> = flow {
val cached = dao.getSharesByExpense(expenseId).first()
emit(Resource.Loading(cached))
if(cached.isEmpty() || forceRefresh) {
try {
val response = api.shareGet("Bearer $token", expenseId, IDType.Expense)
if(response.isSuccessful) {
println("Body: ${response.body()}")
val remoteShare = response.body()?.shares ?: emptyList()
remoteShare.forEach { dao.insertShare(it) }
}
} catch(e: Exception) {
emit(Resource.Error("Network Error: ${e.localizedMessage}", cached))
}
}
dao.getSharesByExpense(expenseId).collect { emit(Resource.Success(it)) }
}
}

View File

@@ -0,0 +1,7 @@
package de.miaurizius.shap_planner.repository
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
class Success<T>(data: T): Resource<T>(data)
class Loading<T>(data: T? = null): Resource<T>(data)
class Error<T>(message: String, data: T? = null): Resource<T>(data, message)
}

View File

@@ -0,0 +1,32 @@
package de.miaurizius.shap_planner.repository
import de.miaurizius.shap_planner.entities.User
import de.miaurizius.shap_planner.entities.UserDao
import de.miaurizius.shap_planner.network.APIService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import java.util.UUID
class UserRepository(
private val dao: UserDao,
private val api: APIService
) {
fun getUser(token: String, userId: UUID, forceRefresh: Boolean = false): Flow<Resource<User>> = flow {
val cached = dao.getUserById(userId).first()
emit(Resource.Loading(cached))
if(cached == null || forceRefresh) {
try {
val response = api.userinfo("Bearer $token", userId)
if(response.isSuccessful) {
println("Body: ${response.body()}")
response.body()?.let { remoteUser -> dao.insertUser(remoteUser) }
}
} catch(e: Exception) {
emit(Resource.Error("Network-Error: ${e.localizedMessage}", cached))
}
}
dao.getUserById(userId).collect { user -> if(user != null) emit(Resource.Success(user)) else emit(
Resource.Error("User nicht gefunden", null)) }
}
}

View File

@@ -0,0 +1,48 @@
package de.miaurizius.shap_planner.room
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao
import de.miaurizius.shap_planner.entities.Expense
import de.miaurizius.shap_planner.entities.ExpenseDao
import de.miaurizius.shap_planner.entities.ExpenseShare
import de.miaurizius.shap_planner.entities.ExpenseShareDao
import de.miaurizius.shap_planner.entities.User
import de.miaurizius.shap_planner.entities.UserDao
class Converters {
@TypeConverter
fun fromList(list: List<String>?): String? = list?.joinToString(",")
@TypeConverter
fun toList(data: String?): List<String>? = data?.split(",")?.map { it.trim() }
}
@Database(entities = [Account::class, Expense::class, ExpenseShare::class, User::class], version = 6)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
abstract fun expenseDao(): ExpenseDao
abstract fun expenseShareDao(): ExpenseShareDao
abstract fun userDao(): UserDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"shap_planner_database"
).fallbackToDestructiveMigration(true).build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,89 @@
package de.miaurizius.shap_planner.ui
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 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.ExpenseCreationScreen
import de.miaurizius.shap_planner.ui.screens.ExpenseDetailScreen
import de.miaurizius.shap_planner.ui.screens.LoginScreen
import de.miaurizius.shap_planner.viewmodels.ExpenseCreationViewModel
import de.miaurizius.shap_planner.viewmodels.ExpenseDetailViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@Composable
fun AppContent(
// Login
accountList: List<Account>,
selectedAccount: Account?,
showLoginForNewAccount: Boolean,
onLogin: (String, String, String) -> Unit,
// Expenses
onExpenseClick: (Expense) -> Unit,
// Account
onSelectAccount: (Account) -> Unit,
onLogoutAccount: () -> Unit,
onAddAccountClick: () -> Unit,
onDeleteAccount: () -> Unit,
// Session
sessionState: SessionState,
onValidateSession: () -> Unit,
onSessionInvalid: () -> Unit,
//Important
viewModel: MainViewModel,
detailViewModel: ExpenseDetailViewModel,
creationViewModel: ExpenseCreationViewModel
) {
var selectedExpense by remember { mutableStateOf<Expense?>(null) }
var showAddExpenseScreen by remember { mutableStateOf(false) }
when {
showAddExpenseScreen -> {
ExpenseCreationScreen(
account = selectedAccount!!,
viewModel = creationViewModel,
onBack = { showAddExpenseScreen = false },
onSaved = { showAddExpenseScreen = false },
)
}
selectedExpense != null -> {
ExpenseDetailScreen(
expense = selectedExpense!!,
account = selectedAccount!!,
viewModel = detailViewModel,
onBack = { selectedExpense = null }
)
}
showLoginForNewAccount -> LoginScreen(onLogin)
accountList.isEmpty() -> LoginScreen(onLogin)
selectedAccount != null -> DashboardScreen(
// Data and regarding Methods
account = selectedAccount,
onExpenseClick = { selectedExpense = it },
// Default Methods
mainViewModel = viewModel,
onBack = onLogoutAccount,
onDelete = onDeleteAccount,
sessionState = sessionState,
onValidate = onValidateSession,
onSessionInvalid = onSessionInvalid,
onAddExpenseClick = { showAddExpenseScreen = true },
)
else -> AccountSelectionScreen(
accounts = accountList,
onAccountClick = onSelectAccount,
onAddAccountClick = onAddAccountClick
)
}
}

View File

@@ -0,0 +1,188 @@
package de.miaurizius.shap_planner.ui.screens
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.size
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSelectionScreen(
accounts: List<Account>,
onAccountClick: (Account) -> Unit,
onAddAccountClick: () -> Unit
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("ShAp-Planner", fontWeight = FontWeight.Black) }
)
},
bottomBar = {
Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 2.dp
) {
Button(
onClick = onAddAccountClick,
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
.navigationBarsPadding()
.height(56.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add New Account", style = MaterialTheme.typography.titleMedium)
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 20.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Welcome back!",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Select an account to continue",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 16.dp)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) {
if (accounts.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxHeight(0.6f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
"No accounts yet.\nTap 'Add New Account' to start.",
textAlign = TextAlign.Center,
color = Color.Gray
)
}
}
}
items(accounts) { account ->
AccountItem(account = account, onClick = { onAccountClick(account) })
}
}
}
}
}
@Composable
fun AccountItem(account: Account, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = MaterialTheme.shapes.large,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Stylized Avatar with Initial
Surface(
modifier = Modifier.size(52.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = account.name.take(1).uppercase(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = account.wgName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
}
}
// Trailing Chevron
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.outline
)
}
}
}

View File

@@ -0,0 +1,217 @@
package de.miaurizius.shap_planner.ui.screens
import android.annotation.SuppressLint
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.repository.Resource
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
account: Account,
onExpenseClick: (Expense) -> Unit,
onAddExpenseClick: () -> Unit,
mainViewModel: MainViewModel,
onBack: () -> Unit,
onDelete: () -> Unit,
sessionState: SessionState,
onValidate: () -> Unit,
onSessionInvalid: () -> Unit
) {
val expenseResource by mainViewModel.expenseResource.collectAsState()
LaunchedEffect(Unit) { onValidate() }
LaunchedEffect(account) { mainViewModel.loadExpenses(account, forceRefresh = false) }
if (sessionState == SessionState.Invalid) {
LaunchedEffect(Unit) { onSessionInvalid() }
return
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(account.wgName, style = MaterialTheme.typography.titleLarge)
Text("Household", style = MaterialTheme.typography.labelSmall)
}
},
actions = {
IconButton(onClick = onBack) {
Icon(Icons.Default.Refresh, "Switch")
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
)
},
floatingActionButton = {
androidx.compose.material3.FloatingActionButton(
onClick = onAddExpenseClick,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.Add, "Neu")
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, ${account.name}!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
SummaryCard(expenses = expenseResource.data ?: emptyList())
}
Text(
text = "Latest expenses",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.secondary
)
Box(modifier = Modifier.fillMaxSize()) {
if (expenseResource is Resource.Loading && expenseResource.data?.isEmpty() == true) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(expenseResource.data ?: emptyList()) { expense ->
ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) })
}
}
}
}
}
}
@SuppressLint("DefaultLocale")
@Composable
fun SummaryCard(expenses: List<Expense>) {
val total = expenses.sumOf { it.amount } / 100.0
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text("Total expenditure", style = MaterialTheme.typography.labelMedium)
Text(
text = String.format("%.2f €", total),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Black
)
}
}
}
@SuppressLint("DefaultLocale")
@Composable
fun ExpenseItem(expense: Expense, onClick: () -> Unit) {
androidx.compose.material3.ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = MaterialTheme.shapes.medium,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = androidx.compose.foundation.shape.CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = expense.title.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = expense.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
Text(
text = String.format("%.2f €", expense.amount / 100.0),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.ExtraBold,
color = MaterialTheme.colorScheme.primary
)
}
}
}

View File

@@ -0,0 +1,249 @@
package de.miaurizius.shap_planner.ui.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.viewmodels.ExpenseCreationViewModel
@SuppressLint("DefaultLocale")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExpenseCreationScreen(
account: Account,
viewModel: ExpenseCreationViewModel,
onSaved: () -> Unit,
onBack: () -> Unit
) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var totalAmountStr by remember { mutableStateOf("") }
val attachmentUris = remember { mutableStateListOf<android.net.Uri>() }
val userShares = remember { mutableStateMapOf<java.util.UUID, String>() }
val users by viewModel.users.collectAsState()
// Real-time calculation logic
val totalCents = (totalAmountStr.replace(",", ".").toDoubleOrNull() ?: 0.0) * 100
val distributedCents = userShares.values.sumOf {
(it.replace(",", ".").toDoubleOrNull() ?: 0.0) * 100
}.toLong()
val diff = totalCents.toLong() - distributedCents
val isAmountMatched = totalCents > 0 && diff == 0L
// File Picker for multiple files (Images & PDFs)
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
contract = androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents()
) { uris ->
attachmentUris.addAll(uris)
}
LaunchedEffect(Unit) { viewModel.loadUsers() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("New Expense") },
navigationIcon = { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
} }
)
}
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header Info
item {
Spacer(modifier = Modifier.height(8.dp))
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = totalAmountStr,
onValueChange = { totalAmountStr = it },
label = { Text("Total Amount (€) *") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
}
}
}
// Optional Description & Multi-File Attachments
item {
Text("Additional Info", style = MaterialTheme.typography.titleMedium)
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
Spacer(modifier = Modifier.height(16.dp))
Text("Attachments (${attachmentUris.size})", style = MaterialTheme.typography.labelLarge)
attachmentUris.forEach { uri ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Description, "File", modifier = Modifier.size(20.dp))
Text(" Document attached", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall)
IconButton(onClick = { attachmentUris.remove(uri) }) {
Icon(Icons.Default.Delete, "Remove", tint = Color.Red)
}
}
}
Button(onClick = { launcher.launch("*/*") }) {
Text("Select Files (Images, PDF)")
}
}
}
}
// Split Details & Validation Message
item {
Column {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Split Details *", style = MaterialTheme.typography.titleMedium)
TextButton(onClick = {
if (totalCents > 0) {
val share = String.format("%.2f", (totalCents / users.size) / 100.0)
users.forEach { userShares[it.id] = share }
}
}) { Text("Split Equally") }
}
// VALIDATION MESSAGE
if (totalCents > 0) {
val statusText = when {
diff > 0 -> "Remaining: ${String.format("%.2f", diff / 100.0)}"
diff < 0 -> "Over-allocated: ${String.format("%.2f", Math.abs(diff) / 100.0)}"
else -> "Amount matched! ✓"
}
val statusColor = if (diff == 0L) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
Surface(
color = statusColor.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Text(
text = statusText,
color = statusColor,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
}
}
items(users) { user ->
UserShareInputItem(
userName = user.name,
amount = userShares[user.id] ?: "",
onAmountChange = { userShares[user.id] = it }
)
}
// Save Actions
item {
Button(
onClick = {
viewModel.saveExpense(
account = account,
title = title,
description = description,
amountCents = totalCents.toInt(),
shares = userShares.mapValues { (it.value.replace(",",".").toDoubleOrNull() ?: 0.0).toInt() * 100 }.filter { it.value > 0 },
attachments = attachmentUris.map { it.toString() }
)
onSaved()
},
modifier = Modifier.fillMaxWidth().height(56.dp),
enabled = title.isNotBlank() && isAmountMatched // STRICT VALIDATION
) {
Text("Save Expense")
}
if (totalAmountStr.isNotBlank() && !isAmountMatched) {
Text(
"Sum of shares must equal total amount to save.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
textAlign = TextAlign.Center
)
}
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Cancel")
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
fun UserShareInputItem(userName: String, amount: String, onAmountChange: (String) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = androidx.compose.foundation.shape.CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(userName.take(1).uppercase(), style = MaterialTheme.typography.bodySmall)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(userName, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
OutlinedTextField(
value = amount,
onValueChange = onAmountChange,
modifier = Modifier.width(100.dp),
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.End)
)
Spacer(modifier = Modifier.width(4.dp))
Text("")
}
}
}

View File

@@ -0,0 +1,189 @@
package de.miaurizius.shap_planner.ui.screens
import android.annotation.SuppressLint
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.style.TextAlign
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.viewmodels.ExpenseDetailViewModel
@SuppressLint("DefaultLocale")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExpenseDetailScreen(
expense: Expense,
account: Account,
viewModel: ExpenseDetailViewModel,
onBack: () -> Unit
) {
val shares by viewModel.sharesWithUser.collectAsState()
LaunchedEffect(expense) {
viewModel.loadExpenseDetail(account, expense)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Expense Details") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
// Hero Section: Amount & Title
Column(
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Total Amount",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = String.format("%.2f €", expense.amount / 100.0),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Black,
color = MaterialTheme.colorScheme.primary
)
Text(
text = expense.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
// Description Card (only shown if not empty)
if (expense.description.isNotBlank()) {
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Description",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(text = expense.description, style = MaterialTheme.typography.bodyLarge)
}
}
}
Text(
text = "Cost Distribution",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(shares) { item ->
ShareItem(
name = item.user?.name ?: "Unknown User",
amountCents = item.share.share_cents
)
}
}
}
}
}
@SuppressLint("DefaultLocale")
@Composable
fun ShareItem(name: String, amountCents: Int) {
androidx.compose.material3.OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = androidx.compose.material3.CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// User Avatar Circle
Surface(
shape = androidx.compose.foundation.shape.CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = name.take(1).uppercase(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Text(
text = String.format("%.2f €", amountCents / 100.0),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = if (amountCents > 0) Color(0xFF2E7D32) else Color.Gray
)
}
}
}

View File

@@ -0,0 +1,176 @@
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@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("") }
var passwordVisible by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Account", fontWeight = FontWeight.Bold) },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()), // Scrollbar, falls die Tastatur den Platz wegnimmt
horizontalAlignment = Alignment.CenterHorizontally
) {
// App Icon or Welcome Text
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Connect to Server",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "Enter your credentials to link your account",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 32.dp)
)
// Server URL Field
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("your-server.com") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Default.Cloud, contentDescription = null) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next)
)
Spacer(modifier = Modifier.height(16.dp))
// Username Field
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(modifier = Modifier.height(16.dp))
// Password Field
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Default.VpnKey, contentDescription = null) },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
trailingIcon = {
val image = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, contentDescription = "Toggle password visibility")
}
}
)
Spacer(modifier = Modifier.height(32.dp))
// Login Button
Button(
onClick = { onLogin(serverUrl, username, password) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
shape = MaterialTheme.shapes.medium
) {
Text("Connect Account", style = MaterialTheme.typography.titleMedium)
}
if (onBack != null) {
TextButton(onClick = onBack, modifier = Modifier.padding(top = 8.dp)) {
Text("Cancel", color = MaterialTheme.colorScheme.secondary)
}
}
}
}
}

View File

@@ -0,0 +1,66 @@
package de.miaurizius.shap_planner.viewmodels
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.Expense
import de.miaurizius.shap_planner.entities.ExpenseDao
import de.miaurizius.shap_planner.entities.ExpenseShare
import de.miaurizius.shap_planner.entities.ExpenseShareDao
import de.miaurizius.shap_planner.entities.User
import de.miaurizius.shap_planner.entities.UserDao
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.UUID
class ExpenseCreationViewModel(
private val userDao: UserDao,
private val expenseDao: ExpenseDao,
private val shareDao: ExpenseShareDao,
private val tokenStorage: TokenStorage
) : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users
fun loadUsers() {
viewModelScope.launch {
userDao.getAllUsers().collect { _users.value = it }
}
}
fun saveExpense(
account: Account,
title: String,
description: String,
amountCents: Int,
shares: Map<UUID, Int>,
attachments: List<String>
) {
// viewModelScope.launch {
// val expenseId = UUID.randomUUID()
// val newExpense = Expense(
// id = expenseId,
// payer_id = account.id,
// amount = amountCents,
// title = title,
// description = description,
// attachments = if (attachments.isEmpty()) null else attachments,
// created_at = (System.currentTimeMillis() / 1000).toInt(),
// last_updated_at = 0
// )
//
// expenseDao.insertExpense(newExpense)
//
// shares.forEach { (userId, shareCents) ->
// shareDao.insertShare(
// ExpenseShare(UUID.randomUUID(), expenseId, userId, shareCents)
// )
// }
//
// // API POST Request
//
// }
}
}

View File

@@ -0,0 +1,59 @@
package de.miaurizius.shap_planner.viewmodels
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.Expense
import de.miaurizius.shap_planner.entities.ExpenseDao
import de.miaurizius.shap_planner.entities.ExpenseShare
import de.miaurizius.shap_planner.entities.ExpenseShareDao
import de.miaurizius.shap_planner.entities.User
import de.miaurizius.shap_planner.entities.UserDao
import de.miaurizius.shap_planner.network.RetrofitProvider
import de.miaurizius.shap_planner.repository.ExpenseShareRepository
import de.miaurizius.shap_planner.repository.Resource
import de.miaurizius.shap_planner.repository.UserRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class ShareWithUser(
val share: ExpenseShare,
val user: User?
)
class ExpenseDetailViewModel(
private val expenseDao: ExpenseDao,
private val shareDao: ExpenseShareDao,
private val userDao: UserDao,
private val tokenStorage: TokenStorage
) : ViewModel() {
private val _sharesWithUser = MutableStateFlow<List<ShareWithUser>>(emptyList())
val sharesWithUser: StateFlow<List<ShareWithUser>> = _sharesWithUser
fun loadExpenseDetail(account: Account, expense: Expense) {
viewModelScope.launch {
val api = RetrofitProvider.create(account.serverUrl)
val token = tokenStorage.getAccess(account.id.toString()) ?: ""
val shareRepo = ExpenseShareRepository(shareDao, api)
val userRepo = UserRepository(userDao, api)
shareRepo.getSharesByExpenseId(token, expense.id).collect { resource ->
val shares = resource.data ?: emptyList()
val combinedList = shares.map { share ->
val cachedUser = userDao.getUserById(share.user_id).first()
if (cachedUser == null) {
val userResource = userRepo.getUser(token, share.user_id).first { it is Resource.Success || it is Resource.Error }
ShareWithUser(share, userResource.data)
} else {
ShareWithUser(share, cachedUser)
}
}
_sharesWithUser.value = combinedList
}
}
}
}

View File

@@ -0,0 +1,70 @@
package de.miaurizius.shap_planner.viewmodels
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.LoginRequest
import de.miaurizius.shap_planner.network.RetrofitProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale.getDefault
import java.util.UUID
class LoginViewModel(private val prefs: UserPreferences, private val appContext: Context) : ViewModel() {
private val tokenStorage = TokenStorage(appContext)
val isLoggedIn = prefs.isLoggedInFlow.stateIn(viewModelScope, SharingStarted.Lazily, false)
val lastUserId = prefs.lastUserLoginFlow.stateIn(viewModelScope, SharingStarted.Lazily, null)
fun login(serverUrl: String, username: String, password: String, viewModel: MainViewModel) {
viewModelScope.launch {
val api = RetrofitProvider.create(serverUrl)
try {
val response = withContext(Dispatchers.IO) {
api.login(LoginRequest(username.lowercase(getDefault()).trim(), password))
}
if(response.isSuccessful) {
val body = response.body() ?: run {
return@launch
}
val access = body.access_token
val refresh = body.refresh_token
tokenStorage.saveTokens(body.user.id, access, refresh)
val account = Account(
id = UUID.fromString(body.user.id),
name = username.trim(),
wgName = body.wgName,
avatarUrl = null,
serverUrl = serverUrl,
role = body.user.role
)
viewModel.addAccount(account)
prefs.saveLogin(body.user.id)
} else {
println("Login failed: ${response.code()} ${response.errorBody()?.toString()}")
}
} catch(e: Exception) {
e.printStackTrace()
}
}
}
fun logout() {
viewModelScope.launch { prefs.clearLogin() }
}
}

View File

@@ -0,0 +1,113 @@
package de.miaurizius.shap_planner.viewmodels
import androidx.compose.runtime.getValue
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.entities.Expense
import de.miaurizius.shap_planner.entities.ExpenseDao
import de.miaurizius.shap_planner.network.RefreshRequest
import de.miaurizius.shap_planner.network.RetrofitProvider
import de.miaurizius.shap_planner.network.SessionState
import de.miaurizius.shap_planner.repository.ExpenseRepository
import de.miaurizius.shap_planner.repository.Resource
import kotlinx.coroutines.flow.MutableStateFlow
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,
private val expenseDao: ExpenseDao,
private val tokenStorage: TokenStorage
) : ViewModel() {
var selectedAccount by mutableStateOf<Account?>(null)
private set
val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
private set
private val _expenseResource = MutableStateFlow<Resource<List<Expense>>>(Resource.Loading(emptyList()))
val expenseResource: StateFlow<Resource<List<Expense>>> = _expenseResource
fun loadExpenses(account: Account, forceRefresh: Boolean = false) {
viewModelScope.launch {
val api = RetrofitProvider.create(account.serverUrl)
val repo = ExpenseRepository(expenseDao, api)
val accessToken = tokenStorage.getAccess(account.id.toString()) ?: ""
repo.getExpenses(accessToken, forceRefresh).collect { result -> _expenseResource.value = result }
}
}
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
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
}
}
fun selectAccount(account: Account) {
selectedAccount = account
}
fun logoutFromAccount() {
selectedAccount = null
}
}

View File

@@ -1,170 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@@ -1,30 +1,50 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.46"
android:scaleY="0.46"
android:translateX="276.48"
android:translateY="276.48">
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
android:pathData="M764.8,146v119.9l69.5,53.4v-149c0,-13.4 -10.8,-24.2 -24.2,-24.2h-45.3z"
android:fillColor="#FF6339"/>
<path
android:pathData="M226.2,926.8V498.4l312.5,-246.9 312.5,235.4 -3.5,434.1z"
android:fillColor="#ECD300"/>
<path
android:pathData="M851.3,486.9l-312.5,-235.4 -31.1,24.6 271.8,211.6c6.3,4.9 9.9,12.4 9.9,20.4v340.7c0,14.1 -11.4,25.5 -25.5,25.5H226.2v52.5l621.6,-5.8 3.4,-434.1z"
android:fillColor="#E8A200"/>
<path
android:pathData="M799.5,150.5v137.8l25.1,18.2V154.3z"
android:fillColor="#F94A21"/>
<path
android:pathData="M904.5,498.4a50.5,50.5 0,0 1,-30.3 -10.1l-363.9,-270.8 -286.1,223c-22.1,17 -66.9,60.3 -83.9,38.2 -16.9,-22.1 -12.9,-53.7 9.2,-70.7l358.5,-275.7a50.5,50.5 0,0 1,61.1 -0.3l365.8,275.7c22.2,16.7 26.7,48.3 9.9,70.6a50.5,50.5 0,0 1,-40.3 20.1z"
android:fillColor="#76BFFF"/>
<path
android:pathData="M944.8,478.4a50.4,50.4 0,0 0,-9.9 -70.6l-365.8,-275.7a50.4,50.4 0,0 0,-55 -3.7c8.1,2.4 18.2,7.6 30.5,17.7 46.1,37.8 336.4,260.1 347,267.3 10.6,7.1 20.6,22.9 13,38.1 -7.4,14.8 -28.1,24.1 -61.6,8.4 0.8,3.1 17.2,16.2 18.6,19l12.6,9.4a50,50 0,0 0,30.3 10.1,50.3 50.3,0 0,0 40.2,-20.1z"
android:fillColor="#659CF8"/>
<path
android:pathData="M609.4,785.6H452.7a48,48 0,0 1,-48 -48v-156.7c0,-26.5 21.5,-48 48,-48h156.7c26.5,0 48,21.5 48,48v156.7c0.1,26.5 -21.5,48 -48,48z"
android:fillColor="#76BFFF"/>
<path
android:pathData="M657.5,737.5v-156.7c0,-22.5 -15.5,-41.4 -36.4,-46.6v191.3c0,11.6 -9.4,20.9 -20.9,20.9H405.5c4.2,22.3 23.7,39.2 47.2,39.2h156.7c26.5,-0.1 48,-21.6 48,-48.1z"
android:fillColor="#659CF8"/>
<path
android:pathData="M274.7,729a12.8,12.8 0,0 1,-12.8 -12.8v-19.5a12.8,12.8 0,0 1,25.6 0v19.5a12.8,12.8 0,0 1,-12.8 12.8zM274.7,659.7a12.8,12.8 0,0 1,-12.8 -12.8v-103.5c0,-9.2 4.5,-17.9 11.8,-23.3l38,-27.8c5.7,-4.1 13.7,-2.9 17.9,2.8s2.9,13.7 -2.8,17.9l-38,27.8c-0.9,0.6 -1.3,1.6 -1.3,2.7v103.5c0,7.1 -5.7,12.9 -12.8,12.9z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M206.4,330.6c3.3,0 6.6,-1 9.4,-3.2l12.7,-9.8a15.4,15.4 0,0 0,2.8 -21.6,15.4 15.4,0 0,0 -21.6,-2.8l-12.7,9.8a15.4,15.4 0,0 0,-2.8 21.6c3,3.9 7.6,6 12.2,6zM175.2,693.1c8.5,0 15.4,-6.9 15.4,-15.4v-15.7c0,-8.5 -6.9,-15.4 -15.4,-15.4s-15.4,6.9 -15.4,15.4v15.7c0,8.4 6.9,15.4 15.4,15.4z"
android:fillColor="#333333"/>
<path
android:pathData="M941.7,911.5h-75.1V504.6c10.4,6 22.2,9.2 34.4,9.2 22,0 42.1,-10 55.3,-27.5 23,-30.5 16.9,-74 -13.6,-97l-93.1,-70.2V170.2c0,-21.8 -17.7,-39.5 -39.5,-39.5h-52.6c-21.8,0 -39.5,17.7 -39.5,39.5v49.7l-166.3,-125.4c-24.8,-18.7 -59.3,-18.5 -83.9,0.4l-214,164.6c-6.7,5.2 -8,14.8 -2.8,21.6s14.8,8 21.6,2.8l214.1,-164.7a38.6,38.6 0,0 1,46.7 -0.2l391.1,294.8c16.9,12.8 20.4,37 7.6,54 -7.4,9.7 -18.6,15.4 -30.8,15.4 -8.4,0 -16.4,-2.7 -23.1,-7.8l-358.3,-270.1a15.4,15.4 0,0 0,-18.6 0.1L150.2,475.1a38.2,38.2 0,0 1,-28.5 7.7,38.4 38.4,0 0,1 -25.5,-14.7c-13,-16.8 -9.8,-41.1 7.1,-54l53.1,-40.8c6.7,-5.2 8,-14.8 2.8,-21.6s-14.8,-8 -21.6,-2.8L84.5,389.6c-30.3,23.3 -35.9,66.8 -12.7,97.1 11.3,14.6 27.6,24.1 45.9,26.5a68.7,68.7 0,0 0,42.1 -7.8v96.2c0,8.5 6.9,15.4 15.4,15.4s15.4,-6.9 15.4,-15.4V486.9c0,-1.3 -0.2,-2.5 -0.5,-3.7l320.4,-246.4 325.5,245.4v429.3H190.6v-151.3c0,-8.5 -6.9,-15.4 -15.4,-15.4s-15.4,6.9 -15.4,15.4v151.3H87.4c-8.5,0 -15.4,6.9 -15.4,15.4s6.9,15.4 15.4,15.4h854.3c8.5,0 15.4,-6.9 15.4,-15.4s-6.9,-15.3 -15.4,-15.3zM748.7,170.2c0,-4.9 3.9,-8.8 8.8,-8.8h52.6c4.9,0 8.8,3.9 8.8,8.8v125.7l-70.2,-52.9V170.2z"
android:fillColor="#333333"/>
<path
android:pathData="M439,673.3c-8.5,0 -15.4,6.9 -15.4,15.4s6.9,15.4 15.4,15.4H496.6v29.3c0,8.5 6.9,15.4 15.4,15.4s15.4,-6.9 15.4,-15.4v-29.3h57.6c8.5,0 15.4,-6.9 15.4,-15.4s-6.9,-15.4 -15.4,-15.4H527.4v-34.8h57.6c8.5,0 15.4,-6.9 15.4,-15.4s-6.9,-15.4 -15.4,-15.4h-43.3l31.2,-37.8c5.4,-6.6 4.5,-16.2 -2,-21.6a15.3,15.3 0,0 0,-21.6 2l-37.5,45.4 -37.8,-43.5a15.4,15.4 0,0 0,-21.7 -1.5,15.4 15.4,0 0,0 -1.5,21.7l30.6,35.2h-42.3c-8.5,0 -15.4,6.9 -15.4,15.4s6.9,15.4 15.4,15.4H496.6v34.8H439z"
android:fillColor="#333333"/>
<path
android:pathData="M609.4,800.9c35,0 63.4,-28.5 63.4,-63.4v-191c0,-35 -28.5,-63.4 -63.4,-63.4L418.4,483.1c-35,0 -63.4,28.5 -63.4,63.4v191c0,35 28.5,63.4 63.4,63.4h191.1zM385.7,737.5v-191c0,-18 14.6,-32.7 32.7,-32.7h191.1c18,0 32.7,14.6 32.7,32.7v191c0,18 -14.6,32.7 -32.7,32.7L418.4,770.2c-18,0 -32.7,-14.6 -32.7,-32.7z"
android:fillColor="#333333"/>
</group>
</vector>

View File

@@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,3 +1,3 @@
<resources>
<string name="app_name">Shap-Planner</string>
<string name="app_name">ShAp-Planner</string>
</resources>

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>