This commit is contained in:
Rafal Wisniewski
2026-04-02 10:46:41 +02:00
parent c4c9868698
commit 767d54e8f6
20 changed files with 381 additions and 135 deletions

View File

@@ -97,7 +97,7 @@ dependencies {
implementation("androidx.paging:paging-compose:3.4.2")
implementation("androidx.datastore:datastore-preferences:1.2.1")
implementation("com.composables:icons-material-symbols-outlined-android:2.2.1")
implementation(libs.icons.material.symbols.outlined.android)
implementation("com.google.dagger:hilt-android:2.57.1")
ksp("com.google.dagger:hilt-android-compiler:2.57.1")

View File

@@ -16,15 +16,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.paging.compose.collectAsLazyPagingItems
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation
@@ -42,7 +45,9 @@ import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -53,12 +58,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
TripMoneyTheme {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
expenseAndCategoryViewModel.clearOldRates()
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
.collectAsLazyPagingItems()
NavigationDrawer()
}
}
@@ -78,12 +77,12 @@ fun NavigationDrawer() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
var filter by remember { mutableStateOf("") }
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen
CustomNavigationDrawer(navController, drawerState) {
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
topBar = {
if (current == Screens.SETTINGS) TopBarSettings(
navController
@@ -99,7 +98,7 @@ fun NavigationDrawer() {
}
},
isSearchable = current == Screens.LIST_EXPENSE,
onFilterChange = { newFilter -> filter = newFilter})
onFilterChange = { newFilter -> filter = newFilter })
},
bottomBar = { BottomNavigation(navController) }) { innerPadding ->
@@ -109,7 +108,10 @@ fun NavigationDrawer() {
modifier = Modifier.padding(innerPadding)
) {
composable(Screens.LIST_EXPENSE) {
ListExpenseScreen(filter)
ListExpenseScreen(
filter,
initialAutoOpen = shouldTriggerAutoOpen,
onAutoOpenConsumed = { hasHandledStartupOpen = true })
}
composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController)

View File

@@ -120,6 +120,7 @@ private class DatabasePrepopulator(
Trip(
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
endDate = LocalDate.parse("2026-03-15"),
currency = "PLN"
)
)
@@ -127,6 +128,7 @@ private class DatabasePrepopulator(
Trip(
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-15"),
currency = "EUR"
)
)
@@ -134,6 +136,7 @@ private class DatabasePrepopulator(
Trip(
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-15"),
currency = "USD"
)
)

View File

@@ -49,6 +49,14 @@ interface ExpenseDao {
)
fun expenseDto(tripId: Int, filter: String): Flow<List<ExpenseDto>>
@Query("""
SELECT trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0)
FROM trip
LEFT JOIN expense ON expense.trip_id = trip.id
WHERE trip.id = :tripId
""")
fun budgetLeft(tripId: Int): Double
@Delete
suspend fun delete(expense: Expense)
}

View File

@@ -15,11 +15,17 @@ data class Trip(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String,
@ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("end_date") val endDate: LocalDate,
@ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double
){
@ColumnInfo("budget") val budget: Double = 0.0
) {
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(-1, "", LocalDate.now(), Currencies.default().name, budget = 0.0)
val DUMMY = Trip(
-1,
"",
LocalDate.now(),
endDate = LocalDate.now(), Currencies.default().name, budget = 0.0,
)
}
}

View File

@@ -25,6 +25,10 @@ class ExpenseRepository @Inject constructor(
private val exchangeRateRepository: ExchangeRateRepository
) {
fun getBudgetLeft(tripId: Int): Double {
return expenseDao.budgetLeft(tripId)
}
@WorkerThread
suspend fun save(expense: Expense) {
expenseDao.insert(expense)

View File

@@ -1,10 +1,12 @@
package cc.n0th1ng.tripmoney.data.repository
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.ADD_EXPENSE_SWITCH
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.APP_THEME
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.CURRENT_TRIP
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.DEFAULT_CURRENCY
@@ -23,6 +25,7 @@ object PreferenceKeys {
val APP_THEME = intPreferencesKey("app_theme")
val CURRENT_TRIP = intPreferencesKey("current_trip")
val DEFAULT_CURRENCY = stringPreferencesKey("default_currency")
val ADD_EXPENSE_SWITCH = booleanPreferencesKey("add_expense_switch")
}
@@ -34,6 +37,13 @@ class PreferencesRepository @Inject constructor(@ApplicationContext private val
AppTheme.fromValue(value)
}
val currentAddExpenseSwitchFlow: Flow<Boolean> =
context.preferencesDataStore.data.map { prefs ->
val value = prefs[ADD_EXPENSE_SWITCH]
?: false
value
}
val currentTripFlow: Flow<Int> =
context.preferencesDataStore.data.map { prefs ->
prefs[CURRENT_TRIP] ?: -1
@@ -61,6 +71,12 @@ class PreferencesRepository @Inject constructor(@ApplicationContext private val
}
}
suspend fun saveAddExpenseSwitch(value: Boolean) {
context.preferencesDataStore.edit { prefs ->
prefs[ADD_EXPENSE_SWITCH] = value
}
}
}
enum class AppTheme(val value: Int) {

View File

@@ -2,13 +2,10 @@ package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,7 +31,6 @@ import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
@@ -47,7 +43,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -76,8 +71,6 @@ import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R.drawable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.LocalDateTime
@@ -104,7 +97,7 @@ fun AddExpenseBottomSheet(
onDismiss = onDismiss,
expenseDtoToEdit = expenseDtoToEdit,
state = state,
currentTrip = currentTrip!!,
currentTrip = currentTrip ?: Trip.DUMMY,
categories = categories
)
}
@@ -128,10 +121,14 @@ fun AddExpenseBottomSheet(
var amount by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00"
"%.2f".format(expenseDtoToEdit?.expense?.amount ?: 0.00)
)
}
var equationResult by remember {
mutableDoubleStateOf(
expenseDtoToEdit?.expense?.amount ?: 0.00
)
}
var equationResult by remember { mutableDoubleStateOf(0.0) }
val dummyFocusRequester = remember { FocusRequester() }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoryDialog by remember { mutableStateOf(false) }
@@ -193,7 +190,7 @@ fun AddExpenseBottomSheet(
fontWeight = FontWeight.Bold
)
Text(
text = if (amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format(
text = if (amount.contains(Regex("[+/*-]\\d+"))) "%.2f".format(
equationResult
) else "",
fontSize = 14.sp,
@@ -240,7 +237,7 @@ fun AddExpenseBottomSheet(
NumberKeyboard(
modifier = Modifier.fillMaxWidth(),
onOperatorClick = { operator ->
if (amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) {
if (amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+/*-]\\d+"))) {
amount = evaluate(amount).toString()
}
val newText = amount + operator
@@ -369,7 +366,7 @@ private inline fun String.indexOfFirstIndexed(predicate: (index: Int, Char) -> B
}
private fun String.isDoubleTwoDigitsOrEquation(): Boolean {
return this != "0.00" && this.matches(Regex("^(-?(0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?))([+\\/*-](0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?)?)?$"))
return this != "0.00" && this.matches(Regex("^(-?(0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?))([+/*-](0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?)?)?$"))
}
@Composable
@@ -518,7 +515,6 @@ fun KeyboardButton(
text: String? = null,
icon: Painter? = null,
onClick: () -> Unit,
enabled: Boolean = true,
onLongClick: () -> Unit = {},
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary
@@ -574,6 +570,7 @@ fun PreviewAddExpenseDisabled() {
1,
"Trip",
LocalDate.parse("2020-01-01"),
LocalDate.parse("2020-01-15"),
Currencies.entries.random().name
),
categories = categoriesToPreview
@@ -607,13 +604,17 @@ fun PreviewAddExpenseEnabled() {
tripId = 1
),
category = categoriesToPreview[0],
Trip(1, "Włochy", LocalDate.parse("2025-01-02"), "PLN")
Trip(
1, "Włochy", LocalDate.parse("2025-01-02"),
LocalDate.parse("2025-01-15"), "PLN"
)
),
state = sheetState,
currentTrip = Trip(
1,
"Trip",
LocalDate.parse("2020-01-01"),
LocalDate.parse("2020-01-11"),
Currencies.entries.random().name
),
categories = categoriesToPreview

View File

@@ -5,14 +5,14 @@ import androidx.annotation.RequiresApi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DatePickerState
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerState
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -20,17 +20,55 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import cc.n0th1ng.tripmoney.R.*
import java.sql.Time
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Calendar
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePicker(
startDate: LocalDate,
endDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, LocalDate) -> Unit
) {
val datePickerState =
rememberDateRangePickerState(initialSelectedStartDateMillis = startDate.toEpochMilli(),
initialSelectedEndDateMillis = endDate.toEpochMilli())
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val selectedStartDateMillis = datePickerState.selectedStartDateMillis
val selectedEndDateMillis = datePickerState.selectedEndDateMillis
if (selectedStartDateMillis != null && selectedEndDateMillis != null) {
val selectedStartDate = Instant.ofEpochMilli(selectedStartDateMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
val selectedEndDate =
Instant.ofEpochMilli(selectedEndDateMillis).atZone(ZoneId.systemDefault())
.toLocalDate()
onConfirm(selectedStartDate, selectedEndDate)
}
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
}
) {
DateRangePicker(state = datePickerState, showModeToggle = false,
title = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -46,14 +46,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
@@ -73,7 +71,6 @@ import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
@@ -83,7 +80,9 @@ import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen(filter: String) {
fun ListExpenseScreen(filter: String,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit ) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
@@ -97,7 +96,9 @@ fun ListExpenseScreen(filter: String) {
expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate
isRecalculatingRate = isRecalculatingRate,
initialAutoOpen = initialAutoOpen,
onAutoOpenConsumed = onAutoOpenConsumed
)
}
@@ -108,12 +109,23 @@ fun ListExpenseScreen(filter: String) {
fun ListExpenseScreen(
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean
isRecalculatingRate: Boolean,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showBottomSheet by remember { mutableStateOf(false) }
LaunchedEffect(initialAutoOpen) {
if (initialAutoOpen) {
showBottomSheet = true
onAutoOpenConsumed()
}
}
val items = expensesFlow.collectAsLazyPagingItems()
val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) }
var expenseDtoToEdit by remember { mutableStateOf<ExpenseDto?>(null) }
var itemToDelete by remember { mutableStateOf<Expense?>(null) }
@@ -196,7 +208,7 @@ fun ListExpenseScreen(
showBottomSheet = false
},
expenseDtoToEdit = expenseDtoToEdit,
state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
state = sheetState
)
}
}
@@ -216,7 +228,9 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
date.format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier.padding(horizontal = 5.dp).background(Color.White.copy(alpha = 0f)),
modifier = Modifier
.padding(horizontal = 5.dp)
.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
Row(
@@ -446,7 +460,9 @@ fun PreviewListExpenseScreen() {
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
true
isRecalculatingRate = true,
false,
{}
)
}
@@ -497,7 +513,8 @@ private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseListItemUi> {
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01")
startDate = LocalDate.parse("2026-01-01"),
endDate = LocalDate.parse("2026-01-11"),
)
val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()

View File

@@ -19,6 +19,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -69,12 +70,12 @@ import java.nio.file.Files
fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
val currentAddExpenseSwitch by settingsViewModel.addExpenseSwitch.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val context = LocalContext.current
val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope()
@@ -84,6 +85,9 @@ fun SettingsScreen(navController: NavHostController) {
currentTheme = currentTheme,
onThemeSave = { settingsViewModel.setTheme(it) },
onCurrencySave = { settingsViewModel.setDefaultCurrency(it) },
onAddExpenseSwitch = {
settingsViewModel.setCurrentAddExpenseSwitch(it)
},
tripName = tripName,
onExportToCsv = {
scope.launch {
@@ -98,7 +102,8 @@ fun SettingsScreen(navController: NavHostController) {
}
}
},
onCategoriesClick = {navController.navigate(Screens.MANAGE_CATEGORIES)}
onCategoriesClick = { navController.navigate(Screens.MANAGE_CATEGORIES) },
currentAddExpenseSwitch = currentAddExpenseSwitch
)
}
@@ -111,13 +116,14 @@ fun SettingsScreen(
onCurrencySave: (Currencies) -> Unit,
tripName: String,
onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit
onCategoriesClick: () -> Unit,
onAddExpenseSwitch: (Boolean) -> Unit,
currentAddExpenseSwitch: Boolean
) {
Scaffold { padding ->
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoriesDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -161,6 +167,15 @@ fun SettingsScreen(
supportingText = stringResource(string.manage_categories),
iconResource = R.drawable.materialsymbols_ic_label_outlined
)
SettingsListItem(
onClick = onCategoriesClick,
stringResource(string.add_expense),
supportingText = stringResource(string.add_expense_settings),
iconResource = R.drawable.materialsymbols_ic_payments_outlined,
trailingContent = {
Switch(checked = currentAddExpenseSwitch, onCheckedChange = {onAddExpenseSwitch(it)})
}
)
if (showThemeDialog) {
ThemeSelectionDialog(
@@ -209,7 +224,7 @@ fun SettingsListItem(
headlineText: String,
trailingContent: @Composable () -> Unit = {},
supportingText: String,
iconResource: Int
iconResource: Int,
) {
Card {
ListItem(
@@ -226,6 +241,7 @@ fun SettingsListItem(
}
}
@Composable
fun ThemeSelectionDialog(
onDismiss: () -> Unit,
@@ -275,13 +291,16 @@ fun ThemeSelectionDialog(
fun PreviewSettingsScreen() {
TripMoneyTheme {
SettingsScreen(
Currencies.entries.random(),
AppTheme.entries.random(),
{},
{},
"Włochy",
{},
{})
currentDefaultCurrency = Currencies.entries.random(),
currentTheme = AppTheme.entries.random(),
onThemeSave = {},
onCurrencySave = {},
onExportToCsv = {},
tripName = "Włochy",
onCategoriesClick = {},
onAddExpenseSwitch = {},
currentAddExpenseSwitch = false
)
}
}

View File

@@ -14,12 +14,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -31,13 +27,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.core.graphics.toColorLong
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
@@ -66,7 +62,8 @@ fun StatisticsScreen() {
StatisticsScreen(
summaryPerCategoryList,
summaryAmount,
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name)
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name),
expenseAndCategoryViewModel.getBudgetLeft(currentTripId)
)
}
@@ -75,7 +72,8 @@ fun StatisticsScreen() {
fun StatisticsScreen(
summaryPerCategoryList: List<SummaryPerCategory>,
summaryAmount: Double,
tripCurrency: Currencies
tripCurrency: Currencies,
moneyLeft: Double
) {
Column(
modifier = Modifier
@@ -83,48 +81,70 @@ fun StatisticsScreen(
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Summary(summaryAmount, tripCurrency.name)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Summary(
Modifier.weight(1f), -1 * summaryAmount, tripCurrency.name,
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
R.drawable.materialsymbols_ic_payment_arrow_down_outlined,
iconColor = MaterialTheme.colorScheme.error
)
Summary(
Modifier.weight(1f), moneyLeft, tripCurrency.name,
stringResource(cc.n0th1ng.tripmoney.R.string.money_left),
R.drawable.materialsymbols_ic_payments_outlined,
iconColor = colorResource(cc.n0th1ng.tripmoney.R.color.good_green)
)
}
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
@Composable
fun Summary(summaryAmount: Double, currency: String) {
fun Summary(
modifier: Modifier = Modifier,
amount: Double,
currency: String,
text: String,
icon: Int,
iconColor: Color
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
modifier = modifier,
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(5.dp),
painter = painterResource(icon),
tint = iconColor,
contentDescription = null,
)
Text(
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
text,
style = MaterialTheme.typography.titleSmall
)
Text(
"%.2f %s".format(summaryAmount, currency),
style = MaterialTheme.typography.headlineLarge
)
}
Row(
horizontalArrangement = Arrangement.Center
Text(
"%.2f %s".format(amount, currency),
style = MaterialTheme.typography.titleLarge,
)
{
Icon(
painter = painterResource(R.drawable.materialsymbols_ic_payment_arrow_down_outlined),
contentDescription = null,
modifier = Modifier.size(45.dp)
)
}
}
}
}
@@ -217,7 +237,8 @@ fun Preview() {
StatisticsScreen(
summaryPerCategoryList,
summaryAmount = 125.24,
Currencies.entries.random()
Currencies.entries.random(),
432.14
)
}
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -37,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
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.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -47,9 +49,11 @@ import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker
import cc.n0th1ng.tripmoney.screens.listexpense.DateRangePicker
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.pretty
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import io.ktor.http.hostIsIp
import kotlinx.coroutines.CoroutineScope
@@ -98,8 +102,15 @@ fun AddTripBottomSheet(
)
}
var endDate by remember {
mutableStateOf(
tripToEdit?.startDate ?: LocalDate.now()
)
}
var showCurrencyDialog by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var budgetString by remember { mutableStateOf(tripToEdit?.budget?.toString() ?: "") }
var currency by remember { mutableStateOf(tripToEdit?.currency ?: defaultCurrency.name) }
var enableSave by remember { mutableStateOf(tripToEdit != null) }
@@ -127,6 +138,23 @@ fun AddTripBottomSheet(
name = newText
enableSave = !name.isEmpty()
})
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(15.dp),
verticalAlignment = Alignment.CenterVertically
) {
BudgetInput(
modifier = Modifier.fillMaxWidth(0.7f),
budget = budgetString,
onTextChange = { newBudget -> budgetString = newBudget })
CurrencyButton(
modifier = Modifier
.weight(1f)
.fillMaxWidth(1f),
onClick = { showCurrencyDialog = true }, text = currency
)
}
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -137,17 +165,14 @@ fun AddTripBottomSheet(
.weight(1f),
shape = MaterialTheme.shapes.medium,
onClick = { showDatePicker = true }) {
val startDateFormatted = startDate.pretty()
val endDateFormatted = endDate.pretty()
Text(
text = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
text = "$startDateFormatted - $endDateFormatted",
fontSize = 17.sp
)
}
CurrencyButton(
modifier = Modifier
.weight(1f)
.fillMaxWidth(1f),
onClick = { showCurrencyDialog = true }, text = currency
)
}
@@ -157,7 +182,13 @@ fun AddTripBottomSheet(
shape = MaterialTheme.shapes.medium,
onClick = {
val trip =
Trip(name = name, startDate = startDate, currency = currency)
Trip(
name = name,
startDate = startDate,
endDate = endDate,
currency = currency,
budget = budgetString.toDoubleOrNull() ?: 0.0
)
onSave(if (tripToEdit == null) trip else trip.copy(id = tripToEdit.id))
}) {
@@ -182,10 +213,15 @@ fun AddTripBottomSheet(
}
if (showDatePicker) {
DatePicker(startDate, onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
startDate = newDate
showDatePicker = false
})
DateRangePicker(
startDate = startDate,
endDate = endDate,
onDismiss = { showDatePicker = false },
onConfirm = { newStartDate, newEndDate ->
startDate = newStartDate
endDate = newEndDate
showDatePicker = false
})
}
}
@@ -201,6 +237,28 @@ fun NameInput(name: String, onTextChange: (String) -> Unit) {
)
}
@Composable
fun BudgetInput(modifier: Modifier = Modifier, budget: String, onTextChange: (String) -> Unit) {
var text by remember { mutableStateOf(budget) }
OutlinedTextField(
placeholder = { Text("0.0") },
modifier = modifier,
label = { Text(stringResource(R.string.budget)) },
value = text,
onValueChange = { newText ->
val regex = Regex("^\\d*\\.?\\d{0,2}$")
if (regex.matches(newText)) {
text = newText
onTextChange(text)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
)
)
}
@RequiresApi(Build.VERSION_CODES.O)
@SuppressLint("CoroutineCreationDuringComposition")
@@ -231,7 +289,8 @@ fun PreviewAddTripBottomSheetEditTrip() {
AddTripBottomSheet(
{},
{},
Trip(1, "Włochy", LocalDate.parse("2025-01-02"), "PLN"),
Trip(1, "Włochy", LocalDate.parse("2025-01-02"),
LocalDate.parse("2025-01-15"), "PLN", budget = 0.0),
sheetState,
defaultCurrency = Currencies.entries.random()
)

View File

@@ -58,6 +58,7 @@ import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.pretty
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
@@ -204,6 +205,7 @@ fun SwipeToDeleteTripCard(
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun TripCard(
trip: Trip,
@@ -236,15 +238,34 @@ fun TripCard(
Column(
modifier = Modifier.padding(16.dp)
) {
Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name)
Text(trip.startDate.toString())
Text(
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
text = trip.name
)
Text(
style = MaterialTheme.typography.bodySmall,
text = "start: " + trip.startDate.pretty() + "\nend: " + trip.endDate.pretty()
)
}
Column(
modifier = Modifier.padding(end = 20.dp),
horizontalAlignment = Alignment.End) {
Text(
trip.currency.uppercase(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
"budget:",
style = MaterialTheme.typography.bodySmall,
)
Text(
"%.2f".format(trip.budget),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
trip.currency.uppercase(),
modifier = Modifier.padding(20.dp),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
@@ -258,18 +279,22 @@ fun PreviewTripPickerScreen() {
1,
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
currency = "PLN"
endDate = LocalDate.parse("2026-03-14"),
currency = "PLN",
budget = 1053.53
),
Trip(
2,
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-11"),
currency = "EUR"
),
Trip(
3,
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-11"),
currency = "USD"
)
)

View File

@@ -0,0 +1,11 @@
package cc.n0th1ng.tripmoney.utils
import android.os.Build
import androidx.annotation.RequiresApi
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDate.pretty(): String {
return this.format(DateTimeFormatter.ofPattern("dd MMM yyyy"))
}

View File

@@ -40,16 +40,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val tripRepo: TripRepository
) : ViewModel() {
fun archiveCategory(category: Category) {
viewModelScope.launch {
categoryRepo.save(category.copy(archived = true))
}
}
fun deArchiveCategory(category: Category) {
viewModelScope.launch {
categoryRepo.save(category.copy(archived = false))
}
fun getBudgetLeft(tripId: Int): Double {
return expenseRepo.getBudgetLeft(tripId)
}
fun getExpensesDtoPaged(tripId: Int, filter: String = ""): Flow<PagingData<ExpenseDto>> =

View File

@@ -5,15 +5,23 @@ import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.data.repository.PreferencesRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.Provides
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewScoped
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -22,6 +30,7 @@ class SettingsViewModel @Inject constructor(
private val repo: PreferencesRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
private val uiModeManager: UiModeManager =
context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
val theme = repo.themeFlow
@@ -31,6 +40,18 @@ class SettingsViewModel @Inject constructor(
AppTheme.SYSTEM
)
val autoOpenStartupPref = repo.currentAddExpenseSwitchFlow
.take(1)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
val addExpenseSwitch = repo.currentAddExpenseSwitchFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000), false
)
val currentTrip = repo.currentTripFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000),
-1
@@ -47,6 +68,11 @@ class SettingsViewModel @Inject constructor(
}
}
fun setCurrentAddExpenseSwitch(value: Boolean) {
viewModelScope.launch {
repo.saveAddExpenseSwitch(value)
}
}
fun setCurrentTrip(tripId: Int) {
viewModelScope.launch {
repo.saveCurrentTrip(tripId)
@@ -77,5 +103,3 @@ class SettingsViewModel @Inject constructor(
}
}

View File

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="good_green">#A0D585</color>
</resources>

View File

@@ -36,4 +36,7 @@
<string name="you_want_archive">Do you want to archive?</string>
<string name="archive_category_info">No expense will be deleted.</string>
<string name="delete_category_info">Your all expenses with category Hotel will be removed.</string>
<string name="budget">Budget</string>
<string name="money_left">Money left</string>
<string name="add_expense_settings">Open add expense form on startup</string>
</resources>

View File

@@ -1,5 +1,6 @@
[versions]
agp = "8.13.2"
iconsMaterialSymbolsOutlinedAndroid = "2.2.1"
kotlin = "2.2.21"
coreKtx = "1.10.1"
junit = "4.13.2"
@@ -17,6 +18,7 @@ profileinstaller = "1.3.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
icons-material-symbols-outlined-android = { module = "com.composables:icons-material-symbols-outlined-android", version.ref = "iconsMaterialSymbolsOutlinedAndroid" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }