feat: add import from CSV and snackbar

This commit is contained in:
Rafal Wisniewski
2026-05-13 15:53:14 +02:00
parent ae5394aa59
commit 56d12499a2
17 changed files with 257 additions and 53 deletions
@@ -10,8 +10,11 @@ import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -41,6 +44,7 @@ import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.SnackbarViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -80,9 +84,17 @@ fun NavigationDrawer() {
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen
val snackbarHostState = remember { SnackbarHostState() }
val snackbarViewModel: SnackbarViewModel = hiltViewModel()
LaunchedEffect(Unit) {
snackbarViewModel.snackbarManager.messages.collect { message ->
snackbarHostState.showSnackbar(message, withDismissAction = true)
}
}
ReportDrawnWhen { !categories.isEmpty() }
CustomNavigationDrawer(navController, drawerState) {
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
if (current == Screens.SETTINGS) TopBarSettings(
navController
@@ -1,8 +1,6 @@
package cc.n0th1ng.tripmoney.data
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@@ -49,7 +47,6 @@ abstract class TripDatabase : RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides
@Singleton
fun provideTripDatabase(
@@ -76,7 +73,6 @@ object DatabaseModule {
).prepopulate()
}
}
return db
}
@@ -111,7 +107,6 @@ private class DatabasePrepopulator(
private val categoryDao: CategoryDao,
private val expenseDao: ExpenseDao
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() {
tripDao.insert(
@@ -193,7 +188,6 @@ private class DatabasePrepopulator(
),
)
@RequiresApi(Build.VERSION_CODES.O)
val sampleExpenses = (0..150).map { i ->
val datetime = if (i > 4) {
@@ -11,9 +11,14 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CategoryDao {
@Upsert
suspend fun insert(category: Category)
suspend fun insert(category: Category): Long
@Query(
"""
SELECT * FROM category WHERE name = :name LIMIT 1
"""
)
fun getByName(name: String): Flow<Category?>
@Delete
suspend fun delete(category: Category)
@@ -3,7 +3,6 @@ package cc.n0th1ng.tripmoney.data.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Trip
@@ -12,7 +11,7 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface TripDao {
@Upsert
suspend fun insert(trip: Trip)
suspend fun insert(trip: Trip): Long
@Query(
"""
@@ -9,8 +9,12 @@ import javax.inject.Inject
class CategoryRepository @Inject constructor(private val categoryDao: CategoryDao) {
@WorkerThread
suspend fun save(category: Category) {
categoryDao.insert(category)
suspend fun save(category: Category): Long {
return categoryDao.insert(category)
}
fun getByName(name: String): Flow<Category?> {
return categoryDao.getByName(name)
}
@WorkerThread
@@ -1,15 +1,10 @@
package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.service.ExchangeService
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import javax.inject.Inject
@@ -23,7 +18,6 @@ class ExchangeRateRepository @Inject constructor(
exchangeRateDao.insert(exchangeRate)
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double {
if(base == target) return 1.0
val id = ExchangeRate.buildKey(base.name, target.name, date.toString())
@@ -45,7 +39,6 @@ class ExchangeRateRepository @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()
exchangeRateDao.deleteOldRates(cutoffDate)
@@ -1,28 +1,20 @@
package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class TripRepository @Inject constructor(
private val tripDao: TripDao,
private val expenseRepository: ExpenseRepository
private val tripDao: TripDao
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun save(trip: Trip) {
tripDao.insert(trip)
suspend fun save(trip: Trip): Long {
return tripDao.insert(trip)
}
fun getTrips(): Flow<PagingData<Trip>> {
@@ -1,8 +1,6 @@
package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
@@ -77,7 +75,6 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
@@ -104,7 +101,6 @@ fun AddExpenseBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
@@ -558,7 +554,6 @@ val keyboard = listOf(
@SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
@@ -589,7 +584,6 @@ fun PreviewAddExpenseDisabled() {
}
@SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
@@ -100,7 +100,6 @@ fun ListExpenseScreen(
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, search, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
var idToScroll by remember { mutableIntStateOf(-1) }
ListExpenseScreen(
currentTrip = currentTrip,
expensesFlow = expensesFlow,
@@ -1,6 +1,11 @@
package cc.n0th1ng.tripmoney.screens.settings
import android.content.ContentResolver
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
@@ -48,6 +53,7 @@ import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.shareCsv
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.SnackbarViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
import kotlinx.coroutines.launch
@@ -57,6 +63,7 @@ import java.io.File
@Composable
fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val snackbarViewModel: SnackbarViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
val currentAddExpenseSwitch by settingsViewModel.addExpenseSwitch.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
@@ -67,7 +74,31 @@ fun SettingsScreen(navController: NavHostController) {
val context = LocalContext.current
val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope()
val result = remember { mutableStateOf<Uri?>(null) }
val wentWrongMessage = stringResource(string.went_wrong)
val successImportMessage = stringResource(string.import_success)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
result.value = it
if (it != null) {
context.contentResolver.openInputStream(it)?.use { inputStream ->
inputStream.bufferedReader().use { input ->
val csvText = input.readText()
val filename = context.contentResolver.filename(it)
if (filename != null) {
expenseAndCategoryViewModel.importCSV(
csvText, filename,
onError = { ex ->
snackbarViewModel.showMessage(wentWrongMessage)
},
onSuccess = {
snackbarViewModel.showMessage(successImportMessage)
})
}
}
}
}
}
SettingsScreen(
currentDefaultCurrency = currentDefaultCurrency,
currentTheme = currentTheme,
@@ -90,11 +121,29 @@ fun SettingsScreen(navController: NavHostController) {
}
}
},
onImportCsv = {
launcher.launch(arrayOf("*/*"))
},
onCategoriesClick = { navController.navigate(Screens.MANAGE_CATEGORIES) },
currentAddExpenseSwitch = currentAddExpenseSwitch
)
}
private fun ContentResolver.filename(uri: Uri): String? {
val projection = arrayOf<String?>(MediaStore.MediaColumns.DISPLAY_NAME)
val metaCursor = this.query(uri, projection, null, null, null);
if (metaCursor != null) {
try {
if (metaCursor.moveToFirst()) {
return metaCursor.getString(0)
}
} finally {
metaCursor.close();
}
}
return null
}
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen(
@@ -106,7 +155,8 @@ fun SettingsScreen(
onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit,
onAddExpenseSwitch: (Boolean) -> Unit,
currentAddExpenseSwitch: Boolean
currentAddExpenseSwitch: Boolean,
onImportCsv: () -> Unit
) {
Scaffold { padding ->
@@ -143,12 +193,20 @@ fun SettingsScreen(
iconResource = R.drawable.materialsymbols_ic_palette_outlined
)
}
SettingsListItem(
onClick = onExportToCsv,
stringResource(string.export_to_csv),
supportingText = stringResource(string.export_csv_subttext).format(tripName),
iconResource = R.drawable.materialsymbols_ic_csv_outlined
)
SettingsCard(string.import_export) {
SettingsListItem(
onClick = onExportToCsv,
stringResource(string.export_to_csv),
supportingText = stringResource(string.export_csv_subttext).format(tripName),
iconResource = R.drawable.materialsymbols_ic_csv_outlined
)
SettingsListItem(
onClick = onImportCsv,
stringResource(string.import_csv),
supportingText = stringResource(string.import_csv_subtext),
iconResource = R.drawable.materialsymbols_ic_csv_outlined
)
}
SettingsListItem(
onClick = onCategoriesClick,
stringResource(string.categories),
@@ -161,7 +219,9 @@ fun SettingsScreen(
supportingText = stringResource(string.add_expense_settings),
iconResource = R.drawable.materialsymbols_ic_payments_outlined,
trailingContent = {
Switch(checked = currentAddExpenseSwitch, onCheckedChange = {onAddExpenseSwitch(it)})
Switch(
checked = currentAddExpenseSwitch,
onCheckedChange = { onAddExpenseSwitch(it) })
}
)
@@ -287,11 +347,13 @@ fun PreviewSettingsScreen() {
tripName = "Włochy",
onCategoriesClick = {},
onAddExpenseSwitch = {},
currentAddExpenseSwitch = false
)
currentAddExpenseSwitch = false,
onImportCsv = {}
)
}
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
@@ -0,0 +1,98 @@
package cc.n0th1ng.tripmoney.service
import androidx.room.withTransaction
import cc.n0th1ng.tripmoney.data.TripDatabase
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.CategoryRepository
import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import kotlinx.coroutines.flow.first
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import java.time.LocalDateTime
import javax.inject.Inject
class ImportService @Inject() constructor(
private val tripRepo: TripRepository,
private val expenseRepository: ExpenseRepository,
private val categoryRepository: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val db: TripDatabase
) {
suspend fun importCSV(
csv: String,
filename: String,
onError: (Exception) -> Unit,
onSuccess: () -> Unit
) {
try {
db.withTransaction {
val parser = CSVParser.parse(
csv, CSVFormat.DEFAULT.builder().setHeader(
"date", "category", "currency", "amount", "note"
).setSkipHeaderRecord(true).get()
)
val records = parser.records.toList()
val currency = records.map { it.get("currency") }.groupingBy { it }.eachCount()
.maxBy { it.value }.key
val startDate =
records.map { it.get("date") }
.minOfOrNull { LocalDateTime.parse(it.substringBefore(",")).toLocalDate() }
val endDate =
records.map { it.get("date") }
.maxOfOrNull { LocalDateTime.parse(it.substringBefore(",")).toLocalDate() }
if (!Currencies.names().contains(currency.uppercase()))
throw Exception("There is no such currency as $currency")
if (startDate == null || endDate == null) throw Exception("There is no start or end date")
val trip = Trip(
name = filename.substringBefore(".csv"),
startDate = startDate,
endDate = endDate,
currency = currency
)
val tripId = tripRepo.save(trip)
records.forEach {
val dateTime = LocalDateTime.parse(it.get("date").substringBefore(","))
val amount = it.get("amount")
val currency = it.get("currency")
val categoryName = it.get("category")
val note = it.get("note")
val category = categoryRepository.getByName(categoryName).first()
val categoryId = category?.id
?: categoryRepository.save(
Category(
name = categoryName,
icon = Icons.entries.random(),
color = colors.random()
)
).toInt()
val rate = exchangeRateRepository.getRate(
Currencies.valueOf(currency),
Currencies.valueOf(trip.currency),
dateTime.toLocalDate(),
)
val expense = Expense(
amount = amount.toDouble(),
currency = currency,
note = note,
datetime = dateTime,
categoryId = categoryId,
tripId = tripId.toInt(),
)
expenseRepository.save(expense.copy(rate = rate))
}
}
onSuccess()
} catch (ex: Exception) {
onError(ex)
}
}
}
@@ -42,6 +42,7 @@ enum class Currencies {
DZD,
EGP,
ERN,
EUR,
ETB,
FJD,
FKP,
@@ -1,7 +1,5 @@
package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
@@ -19,6 +17,7 @@ import cc.n0th1ng.tripmoney.data.repository.CategoryRepository
import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.service.ImportService
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
@@ -38,7 +37,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val tripRepo: TripRepository
private val tripRepo: TripRepository,
private val importService: ImportService
) : ViewModel() {
fun getBudgetLeft(tripId: Int): Flow<Double?> {
@@ -52,7 +52,6 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged(
tripId: Int,
search: String = "",
@@ -136,14 +135,16 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
file.writer().use { writer ->
CSVPrinter(
writer,
CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount")
CSVFormat.DEFAULT.builder()
.setHeader("date", "category", "currency", "amount", "note").get()
).use { printer ->
expenseRepo.getExpensesDto(tripId).first().forEach { expenseDto ->
printer.printRecord(
expenseDto.expense.datetime,
expenseDto.category.name,
expenseDto.expense.currency,
expenseDto.expense.amount
expenseDto.expense.amount,
expenseDto.expense.note
)
}
@@ -214,6 +215,12 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
fun importCSV(csv: String, filename: String, onError: (Exception) -> Unit, onSuccess: () -> Unit) {
viewModelScope.launch {
importService.importCSV(csv, filename, onError, onSuccess)
}
}
fun clearOldRates() {
viewModelScope.launch {
exchangeRateRepository.clearOldRates()
@@ -0,0 +1,16 @@
package cc.n0th1ng.tripmoney.viewmodel
import jakarta.inject.Inject
import jakarta.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@Singleton
class SnackbarManager @Inject constructor() {
private val _messages = MutableSharedFlow<String>()
val messages = _messages.asSharedFlow()
suspend fun showMessage(message: String) {
_messages.emit(message)
}
}
@@ -0,0 +1,18 @@
package cc.n0th1ng.tripmoney.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class SnackbarViewModel @Inject constructor(
val snackbarManager: SnackbarManager
) : ViewModel() {
fun showMessage(message: String) {
viewModelScope.launch {
snackbarManager.showMessage(message)
}
}
}
+5
View File
@@ -45,4 +45,9 @@
<string name="no_trip_picked">Wybierz wycieczkę żeby zobaczyć wydatki</string>
<string name="no_trip_added">Zacznij budżetowanie od dodania wycieczki</string>
<string name="no_expenses_summary">Brak wydatków do podsumowania</string>
<string name="import_csv">Import z CSV</string>
<string name="import_csv_subtext">Stwórz nową wycieczkę z CSV</string>
<string name="import_export">Import/Eksport</string>
<string name="went_wrong">Coś poszło nie tak.</string>
<string name="import_success">Dane zaimportowane poprawnie!</string>
</resources>
+5
View File
@@ -45,4 +45,9 @@
<string name="no_trip_picked">Select trip to see expenses</string>
<string name="no_trip_added">Start budgeting by adding your trip</string>
<string name="no_expenses_summary">No expenses to summarize</string>
<string name="import_csv">import from CSV</string>
<string name="import_csv_subtext">Create new trip from CSV</string>
<string name="import_export">Import/Export</string>
<string name="went_wrong">Sorry, something went wrong.</string>
<string name="import_success">Data imported successfully!</string>
</resources>