feat: add import from CSV and snackbar
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user