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.foundation.layout.padding
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -41,6 +44,7 @@ import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.SnackbarViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -80,9 +84,17 @@ fun NavigationDrawer() {
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState() val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) } var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen 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() } ReportDrawnWhen { !categories.isEmpty() }
CustomNavigationDrawer(navController, drawerState) { CustomNavigationDrawer(navController, drawerState) {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
if (current == Screens.SETTINGS) TopBarSettings( if (current == Screens.SETTINGS) TopBarSettings(
navController navController
@@ -1,8 +1,6 @@
package cc.n0th1ng.tripmoney.data package cc.n0th1ng.tripmoney.data
import android.content.Context import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@@ -49,7 +47,6 @@ abstract class TripDatabase : RoomDatabase() {
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides @Provides
@Singleton @Singleton
fun provideTripDatabase( fun provideTripDatabase(
@@ -76,7 +73,6 @@ object DatabaseModule {
).prepopulate() ).prepopulate()
} }
} }
return db return db
} }
@@ -111,7 +107,6 @@ private class DatabasePrepopulator(
private val categoryDao: CategoryDao, private val categoryDao: CategoryDao,
private val expenseDao: ExpenseDao private val expenseDao: ExpenseDao
) { ) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() { suspend fun prepopulate() {
tripDao.insert( tripDao.insert(
@@ -193,7 +188,6 @@ private class DatabasePrepopulator(
), ),
) )
@RequiresApi(Build.VERSION_CODES.O)
val sampleExpenses = (0..150).map { i -> val sampleExpenses = (0..150).map { i ->
val datetime = if (i > 4) { val datetime = if (i > 4) {
@@ -11,9 +11,14 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface CategoryDao { interface CategoryDao {
@Upsert @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 @Delete
suspend fun delete(category: Category) suspend fun delete(category: Category)
@@ -3,7 +3,6 @@ package cc.n0th1ng.tripmoney.data.dao
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
@@ -12,7 +11,7 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TripDao { interface TripDao {
@Upsert @Upsert
suspend fun insert(trip: Trip) suspend fun insert(trip: Trip): Long
@Query( @Query(
""" """
@@ -9,8 +9,12 @@ import javax.inject.Inject
class CategoryRepository @Inject constructor(private val categoryDao: CategoryDao) { class CategoryRepository @Inject constructor(private val categoryDao: CategoryDao) {
@WorkerThread @WorkerThread
suspend fun save(category: Category) { suspend fun save(category: Category): Long {
categoryDao.insert(category) return categoryDao.insert(category)
}
fun getByName(name: String): Flow<Category?> {
return categoryDao.getByName(name)
} }
@WorkerThread @WorkerThread
@@ -1,15 +1,10 @@
package cc.n0th1ng.tripmoney.data.repository package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao 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.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.service.ExchangeService import cc.n0th1ng.tripmoney.service.ExchangeService
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
@@ -23,7 +18,6 @@ class ExchangeRateRepository @Inject constructor(
exchangeRateDao.insert(exchangeRate) exchangeRateDao.insert(exchangeRate)
} }
@RequiresApi(Build.VERSION_CODES.O)
suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double { suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double {
if(base == target) return 1.0 if(base == target) return 1.0
val id = ExchangeRate.buildKey(base.name, target.name, date.toString()) 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) { suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString() val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()
exchangeRateDao.deleteOldRates(cutoffDate) exchangeRateDao.deleteOldRates(cutoffDate)
@@ -1,28 +1,20 @@
package cc.n0th1ng.tripmoney.data.repository package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.TripDao import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip 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.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class TripRepository @Inject constructor( class TripRepository @Inject constructor(
private val tripDao: TripDao, private val tripDao: TripDao
private val expenseRepository: ExpenseRepository
) { ) {
@RequiresApi(Build.VERSION_CODES.O) suspend fun save(trip: Trip): Long {
suspend fun save(trip: Trip) { return tripDao.insert(trip)
tripDao.insert(trip)
} }
fun getTrips(): Flow<PagingData<Trip>> { fun getTrips(): Flow<PagingData<Trip>> {
@@ -1,8 +1,6 @@
package cc.n0th1ng.tripmoney.screens.addexpense package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@@ -77,7 +75,6 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun AddExpenseBottomSheet( fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit, onSave: (Expense) -> Unit,
@@ -104,7 +101,6 @@ fun AddExpenseBottomSheet(
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun AddExpenseBottomSheet( fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit, onSave: (Expense) -> Unit,
@@ -558,7 +554,6 @@ val keyboard = listOf(
@SuppressLint("CoroutineCreationDuringComposition") @SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@AllPreviews @AllPreviews
@Composable @Composable
@@ -589,7 +584,6 @@ fun PreviewAddExpenseDisabled() {
} }
@SuppressLint("CoroutineCreationDuringComposition") @SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@AllPreviews @AllPreviews
@Composable @Composable
@@ -100,7 +100,6 @@ fun ListExpenseScreen(
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, search, filter) expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, search, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState() val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
var idToScroll by remember { mutableIntStateOf(-1) } var idToScroll by remember { mutableIntStateOf(-1) }
ListExpenseScreen( ListExpenseScreen(
currentTrip = currentTrip, currentTrip = currentTrip,
expensesFlow = expensesFlow, expensesFlow = expensesFlow,
@@ -1,6 +1,11 @@
package cc.n0th1ng.tripmoney.screens.settings package cc.n0th1ng.tripmoney.screens.settings
import android.content.ContentResolver
import android.net.Uri
import android.os.Build 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.RequiresApi
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable 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.utils.shareCsv
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.SnackbarViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R import com.composables.icons.materialsymbols.outlined.R
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -57,6 +63,7 @@ import java.io.File
@Composable @Composable
fun SettingsScreen(navController: NavHostController) { fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val snackbarViewModel: SnackbarViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState() val currentTheme by settingsViewModel.theme.collectAsState()
val currentAddExpenseSwitch by settingsViewModel.addExpenseSwitch.collectAsState() val currentAddExpenseSwitch by settingsViewModel.addExpenseSwitch.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState() val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
@@ -67,7 +74,31 @@ fun SettingsScreen(navController: NavHostController) {
val context = LocalContext.current val context = LocalContext.current
val tripName = currentTrip?.name ?: "" val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope() 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( SettingsScreen(
currentDefaultCurrency = currentDefaultCurrency, currentDefaultCurrency = currentDefaultCurrency,
currentTheme = currentTheme, currentTheme = currentTheme,
@@ -90,11 +121,29 @@ fun SettingsScreen(navController: NavHostController) {
} }
} }
}, },
onImportCsv = {
launcher.launch(arrayOf("*/*"))
},
onCategoriesClick = { navController.navigate(Screens.MANAGE_CATEGORIES) }, onCategoriesClick = { navController.navigate(Screens.MANAGE_CATEGORIES) },
currentAddExpenseSwitch = currentAddExpenseSwitch 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) @RequiresApi(Build.VERSION_CODES.S)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -106,7 +155,8 @@ fun SettingsScreen(
onExportToCsv: () -> Unit, onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit, onCategoriesClick: () -> Unit,
onAddExpenseSwitch: (Boolean) -> Unit, onAddExpenseSwitch: (Boolean) -> Unit,
currentAddExpenseSwitch: Boolean currentAddExpenseSwitch: Boolean,
onImportCsv: () -> Unit
) { ) {
Scaffold { padding -> Scaffold { padding ->
@@ -143,12 +193,20 @@ fun SettingsScreen(
iconResource = R.drawable.materialsymbols_ic_palette_outlined iconResource = R.drawable.materialsymbols_ic_palette_outlined
) )
} }
SettingsListItem( SettingsCard(string.import_export) {
onClick = onExportToCsv, SettingsListItem(
stringResource(string.export_to_csv), onClick = onExportToCsv,
supportingText = stringResource(string.export_csv_subttext).format(tripName), stringResource(string.export_to_csv),
iconResource = R.drawable.materialsymbols_ic_csv_outlined 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( SettingsListItem(
onClick = onCategoriesClick, onClick = onCategoriesClick,
stringResource(string.categories), stringResource(string.categories),
@@ -161,7 +219,9 @@ fun SettingsScreen(
supportingText = stringResource(string.add_expense_settings), supportingText = stringResource(string.add_expense_settings),
iconResource = R.drawable.materialsymbols_ic_payments_outlined, iconResource = R.drawable.materialsymbols_ic_payments_outlined,
trailingContent = { trailingContent = {
Switch(checked = currentAddExpenseSwitch, onCheckedChange = {onAddExpenseSwitch(it)}) Switch(
checked = currentAddExpenseSwitch,
onCheckedChange = { onAddExpenseSwitch(it) })
} }
) )
@@ -287,11 +347,13 @@ fun PreviewSettingsScreen() {
tripName = "Włochy", tripName = "Włochy",
onCategoriesClick = {}, onCategoriesClick = {},
onAddExpenseSwitch = {}, onAddExpenseSwitch = {},
currentAddExpenseSwitch = false currentAddExpenseSwitch = false,
) onImportCsv = {}
)
} }
} }
@RequiresApi(Build.VERSION_CODES.S) @RequiresApi(Build.VERSION_CODES.S)
@AllPreviews @AllPreviews
@Composable @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, DZD,
EGP, EGP,
ERN, ERN,
EUR,
ETB, ETB,
FJD, FJD,
FKP, FKP,
@@ -1,7 +1,5 @@
package cc.n0th1ng.tripmoney.viewmodel package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData 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.ExchangeRateRepository
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.service.ImportService
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -38,7 +37,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository, private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository, private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository, private val exchangeRateRepository: ExchangeRateRepository,
private val tripRepo: TripRepository private val tripRepo: TripRepository,
private val importService: ImportService
) : ViewModel() { ) : ViewModel() {
fun getBudgetLeft(tripId: Int): Flow<Double?> { fun getBudgetLeft(tripId: Int): Flow<Double?> {
@@ -52,7 +52,6 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
): Flow<PagingData<ExpenseDto>> = ): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope) expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged( fun getExpensesWithHeadersPaged(
tripId: Int, tripId: Int,
search: String = "", search: String = "",
@@ -136,14 +135,16 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
file.writer().use { writer -> file.writer().use { writer ->
CSVPrinter( CSVPrinter(
writer, writer,
CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount") CSVFormat.DEFAULT.builder()
.setHeader("date", "category", "currency", "amount", "note").get()
).use { printer -> ).use { printer ->
expenseRepo.getExpensesDto(tripId).first().forEach { expenseDto -> expenseRepo.getExpensesDto(tripId).first().forEach { expenseDto ->
printer.printRecord( printer.printRecord(
expenseDto.expense.datetime, expenseDto.expense.datetime,
expenseDto.category.name, expenseDto.category.name,
expenseDto.expense.currency, 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() { fun clearOldRates() {
viewModelScope.launch { viewModelScope.launch {
exchangeRateRepository.clearOldRates() 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_picked">Wybierz wycieczkę żeby zobaczyć wydatki</string>
<string name="no_trip_added">Zacznij budżetowanie od dodania wycieczki</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="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> </resources>
+5
View File
@@ -45,4 +45,9 @@
<string name="no_trip_picked">Select trip to see expenses</string> <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_trip_added">Start budgeting by adding your trip</string>
<string name="no_expenses_summary">No expenses to summarize</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> </resources>