Compare commits

...

21 Commits

Author SHA1 Message Date
Rafal Wisniewski
ae5394aa59 fix: add default colors and icons 2026-05-06 14:01:46 +02:00
Rafal Wisniewski
dae0212cf9 fix: sort stats per day 2026-05-06 13:03:17 +02:00
Rafal Wisniewski
270ff4fa07 fix: add date to filter 2026-05-06 12:47:11 +02:00
Rafal Wisniewski
f83bf62655 fix: add search to category picker 2026-05-06 10:49:23 +02:00
Rafal Wisniewski
6c067f64ce fix: add search to category picker 2026-05-06 10:49:09 +02:00
Rafal Wisniewski
79551ab69d fix: search & filter on list expense screen 2026-05-04 22:01:38 +02:00
Rafal Wisniewski
38f3760cef fix: math on add expense screen 2026-05-04 21:57:23 +02:00
Rafal Wisniewski
9225f2275f fix: adjust style of delete confirmation dialog 2026-05-04 21:42:55 +02:00
Rafal Wisniewski
aad0de1499 fix: scroll to date when clicked on stats screen 2026-05-04 15:44:32 +02:00
Rafal Wisniewski
5fb54bf18e fix: scroll to newly added item 2026-05-04 15:12:19 +02:00
Rafal Wisniewski
bf9309a155 fix: add currencies and search to them 2026-05-03 22:34:52 +02:00
Rafal Wisniewski
cc10ddabbe feat: add categories per day stats 2026-05-01 15:13:58 +02:00
Rafal Wisniewski
3286bcf87a fix: add info text when no data 2026-05-01 13:10:27 +02:00
dfe9dbd08b Merge pull request 'init' (#48) from develop into main
Reviewed-on: #48
2026-04-30 10:35:57 +02:00
Rafal Wisniewski
bfbb1056d7 init 2026-04-30 09:58:14 +02:00
Rafal Wisniewski
43aec61c75 init 2026-04-29 15:59:17 +02:00
Rafal Wisniewski
e6c8cf5cd3 init 2026-04-29 15:58:20 +02:00
Rafal Wisniewski
2ab7ef3f65 init 2026-04-29 15:42:57 +02:00
Rafal Wisniewski
664df1e5a1 init 2026-04-27 19:58:38 +02:00
Rafal Wisniewski
0518da44d7 init 2026-04-27 19:29:19 +02:00
Rafal Wisniewski
795ce9812a init 2026-04-25 13:32:46 +02:00
43 changed files with 10296 additions and 635 deletions

View File

@@ -0,0 +1 @@
-dontobfuscate

View File

@@ -10,19 +10,27 @@ plugins {
android {
namespace = "cc.n0th1ng.tripmoney"
compileSdk = 36
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId = "cc.n0th1ng.tripmoney"
minSdk = 24
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["androidx.compose.ui.test.tagsAsResourceId"] = "true"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
release {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
@@ -31,25 +39,21 @@ android {
)
}
create("benchmark") {
initWith(getByName("release"))
// 🔑 Critical settings for Macrobenchmark
isDebuggable = false
isMinifyEnabled = false
isShrinkResources = false
// Use release signing if needed (optional for local)
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
isDebuggable = false
proguardFiles("baseline-profiles-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
}
buildFeatures {
@@ -75,49 +79,35 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
"baselineProfile"(project(":baselineprofile"))
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
val room_version = "2.8.4"
coreLibraryDesugaring(libs.tools.desugar.jdk.libs)
implementation("androidx.room:room-runtime:$room_version")
implementation(libs.androidx.room.runtime)
// If this project uses any Kotlin source, use Kotlin Symbol Processing (KSP)
// See Add the KSP plugin to your project
ksp("androidx.room:room-compiler:$room_version")
ksp(libs.androidx.room.compiler)
// optional - Kotlin Extensions and Coroutines support for Room
implementation("androidx.room:room-ktx:$room_version")
// optional - RxJava2 support for Room
implementation("androidx.room:room-rxjava2:$room_version")
// optional - RxJava3 support for Room
implementation("androidx.room:room-rxjava3:$room_version")
// optional - Guava support for Room, including Optional and ListenableFuture
implementation("androidx.room:room-guava:$room_version")
// optional - Test helpers
testImplementation("androidx.room:room-testing:$room_version")
// optional - Paging 3 Integration
implementation("androidx.room:room-paging:$room_version")
implementation("androidx.paging:paging-runtime:3.4.2")
implementation("androidx.paging:paging-compose:3.4.2")
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.rxjava2)
implementation(libs.androidx.room.rxjava3)
implementation(libs.androidx.room.guava)
testImplementation(libs.androidx.room.testing)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.icons.material.symbols.outlined.android)
implementation(libs.icons.material.symbols.outlined.filled.android)
implementation("com.google.dagger:hilt-android:2.57.1")
ksp("com.google.dagger:hilt-android-compiler:2.57.1")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
implementation("io.ktor:ktor-client-core:3.4.1")
implementation("io.ktor:ktor-client-okhttp:3.4.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0")
implementation("org.apache.commons:commons-csv:1.5")
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.kotlinx.serialization.json.jvm)
implementation(libs.commons.csv)
}

View File

@@ -19,3 +19,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings

View File

@@ -12,16 +12,22 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TripMoney">
<profileable
android:shell="true"
tools:targetApi="29" />
<activity
android:screenOrientation="portrait"
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.TripMoney">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -29,9 +35,8 @@
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package cc.n0th1ng.tripmoney
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
@@ -24,6 +25,7 @@ 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 cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation
@@ -42,6 +44,7 @@ import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.time.LocalDate
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -77,7 +80,7 @@ fun NavigationDrawer() {
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen
ReportDrawnWhen { !categories.isEmpty() }
CustomNavigationDrawer(navController, drawerState) {
Scaffold(
topBar = {
@@ -94,7 +97,7 @@ fun NavigationDrawer() {
}
}
},
isSearchable = current == Screens.LIST_EXPENSE,
isSearchable = current != null && current.contains(Screens.LIST_EXPENSE),
onSearchChange = { newSearch -> search = newSearch },
onFilterChange = { newFilter -> filter = newFilter },
categories = categories,
@@ -108,17 +111,22 @@ fun NavigationDrawer() {
startDestination = if (currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
modifier = Modifier.padding(innerPadding)
) {
composable(Screens.LIST_EXPENSE) {
composable(
Screens.LIST_EXPENSE + "?dateToScroll={dateToScroll}",
arguments = listOf(navArgument("dateToScroll") { defaultValue = "" })
) { backStackEntry ->
ListExpenseScreen(
filter = filter, search = search,
initialAutoOpen = shouldTriggerAutoOpen,
onAutoOpenConsumed = { hasHandledStartupOpen = true })
onAutoOpenConsumed = { hasHandledStartupOpen = true },
dateToScroll = backStackEntry.arguments?.getString("dateToScroll") ?: ""
)
}
composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController)
}
composable(Screens.STATISTICS) {
StatisticsScreen()
StatisticsScreen(navController)
}
composable(Screens.SETTINGS) {
SettingsScreen(navController)
@@ -133,7 +141,9 @@ fun NavigationDrawer() {
data class Filter(
val categories: List<Category> = emptyList(), val startAmount: Double = 0.0,
val endAmount: Double = Double.MAX_VALUE
val endAmount: Double = Double.MAX_VALUE,
val startDate: LocalDate = LocalDate.MIN,
val endDate: LocalDate = LocalDate.MAX
) {
fun with(category: Category): Filter {
return this.copy(categories = categories + category)
@@ -147,7 +157,19 @@ data class Filter(
return this.copy(endAmount = amount)
}
fun withStartDate(date: LocalDate): Filter {
return this.copy(startDate = date)
}
fun withEndDate(date: LocalDate): Filter {
return this.copy(endDate = date)
}
fun without(category: Category): Filter {
return this.copy(categories = categories - category)
}
fun isDefault(): Boolean {
return this.categories.isEmpty() && startAmount == 0.0 && endAmount == Double.MAX_VALUE && startDate == LocalDate.MIN && endDate == LocalDate.MAX
}
}

View File

@@ -7,6 +7,7 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import cc.n0th1ng.tripmoney.BuildConfig
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
@@ -14,9 +15,7 @@ import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
@@ -26,9 +25,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
@@ -36,11 +33,9 @@ import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.inject.Singleton
import kotlin.random.Random
import kotlin.random.nextInt
@Database(
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class],
version = 1
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1
)
@TypeConverters(Converters::class)
abstract class TripDatabase : RoomDatabase() {
@@ -54,24 +49,25 @@ abstract class TripDatabase : RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides
@Singleton
fun provideTripDatabase(
@ApplicationContext context: Context
): TripDatabase {
val db: TripDatabase = Room.inMemoryDatabaseBuilder(
// val db: TripDatabase = Room.databaseBuilder(
// name = "tripmoney_db",
val builder = if (BuildConfig.DEBUG) Room.inMemoryDatabaseBuilder(
context = context, klass = TripDatabase::class.java
) else Room.databaseBuilder(
name = "tripmoney_db",
context = context,
klass = TripDatabase::class.java,
)
.allowMainThreadQueries() // TODO Remove in production!
.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
val db: TripDatabase =
builder.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
.build()
if (BuildConfig.DEBUG) {
CoroutineScope(Dispatchers.IO).launch {
DatabasePrepopulator(
tripDao = db.tripDao(),
@@ -79,6 +75,8 @@ object DatabaseModule {
expenseDao = db.expenseDao()
).prepopulate()
}
}
return db
}
@@ -152,29 +150,46 @@ private class DatabasePrepopulator(
val sampleCategories = listOf(
Category(
name = "Hotel",
icon = Icons.HOTEL,
color = colors.random()
name = "Hotel", icon = Icons.HOTEL, color = colors.random()
),
Category(
name = "Jedzenie",
icon = Icons.RESTAURANT,
color = colors.random()
name = "Restaurants", icon = Icons.RESTAURANT, color = colors.random()
),
Category(
name = "Transport",
icon = Icons.FLIGHT,
color = colors.random()
name = "Transport", icon = Icons.FLIGHT, color = colors.random()
),
Category(
name = "Rozrywka",
icon = Icons.ATTRACTION,
color = colors.random()
name = "Entertainment", icon = Icons.ATTRACTION, color = colors.random()
),
Category(
name = "Zakupy",
icon = Icons.GROCERIES,
color = colors.random()
name = "Groceries", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy1", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy2", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy3", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy4", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy5", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy6", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy7", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy8", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy9", icon = Icons.GROCERIES, color = colors.random()
),
)
@@ -193,15 +208,14 @@ private class DatabasePrepopulator(
val expense = Expense(
categoryId = Random.nextInt(1, 5),
categoryId = Random.nextInt(1, sampleCategories.size),
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1,
5.0
0.1, 5.0
) else 1.0
)
expense

View File

@@ -2,7 +2,6 @@ package cc.n0th1ng.tripmoney.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
@@ -21,7 +20,7 @@ interface CategoryDao {
@Transaction
@Query(
"""
SELECT * FROM category WHERE archived is 0
SELECT * FROM category WHERE archived is 0 ORDER BY name
"""
)
fun categories(): Flow<List<Category>>

View File

@@ -4,9 +4,9 @@ import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import kotlinx.coroutines.flow.Flow
@@ -15,9 +15,11 @@ import kotlinx.coroutines.flow.Flow
interface ExpenseDao {
@Upsert
suspend fun insert(expense: Expense)
suspend fun insert(expense: Expense): Long
@Transaction
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT * FROM expense
@@ -38,6 +40,12 @@ interface ExpenseDao {
AND (
:endAmount IS NULL OR expense.amount <= :endAmount
)
AND (
:startDate IS NULL OR expense.datetime >= :startDate
)
AND (
:endDate IS NULL OR expense.datetime <= :endDate
)
ORDER BY expense.datetime DESC
"""
@@ -48,7 +56,9 @@ interface ExpenseDao {
categoryIds: List<Int>,
categoriesEmpty: Boolean,
startAmount: Double?,
endAmount: Double?
endAmount: Double?,
startDate: Long?,
endDate: Long?
): PagingSource<Int, ExpenseDto>
@Transaction
@@ -72,7 +82,12 @@ interface ExpenseDao {
AND (
:endAmount IS NULL OR expense.amount <= :endAmount
)
AND (
:startDate IS NULL OR expense.datetime >= :startDate
)
AND (
:endDate IS NULL OR expense.datetime <= :endDate
)
ORDER BY expense.datetime DESC
"""
)
@@ -82,18 +97,24 @@ interface ExpenseDao {
categoryIds: List<Int>,
categoriesEmpty: Boolean,
startAmount: Double?,
endAmount: Double?
endAmount: Double?,
startDate: Long?,
endDate: Long?
): Flow<List<ExpenseDto>>
@Query(
"""
SELECT trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0)
SELECT
CASE
WHEN trip.budget = 0 THEN NULL
ELSE trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0)
END
FROM trip
LEFT JOIN expense ON expense.trip_id = trip.id
WHERE trip.id = :tripId
"""
)
fun budgetLeft(tripId: Int): Double
fun budgetLeft(tripId: Int): Flow<Double?>
@Delete
suspend fun delete(expense: Expense)

View File

@@ -2,7 +2,7 @@ package cc.n0th1ng.tripmoney.data.dto
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import java.time.LocalDate
data class SummaryPerCategory(
val category: Category,
@@ -11,12 +11,8 @@ data class SummaryPerCategory(
val currency: Currencies
)
data class SummaryPerCategoryRaw(
val categoryId: Int,
val categoryName: String,
val icon: Icons,
val color: String,
data class SummaryPerDay(
val day: LocalDate,
val amount: Double,
val currency: String
val percent: Float
)

View File

@@ -5,6 +5,7 @@ import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import java.time.LocalDateTime
@@ -17,7 +18,8 @@ import java.time.LocalDateTime
childColumns = arrayOf("category_id"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)]
)],
indices = [Index(value = ["category_id"])]
)
@Immutable
data class Expense(

View File

@@ -19,6 +19,10 @@ data class Trip(
@ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double = 0.0
) {
fun isDummy(): Boolean {
return this.id == -1
}
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(

View File

@@ -1,7 +1,5 @@
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
@@ -10,9 +8,11 @@ import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import java.time.LocalDate
import javax.inject.Inject
class ExpenseRepository @Inject constructor(
@@ -20,13 +20,13 @@ class ExpenseRepository @Inject constructor(
private val exchangeRateRepository: ExchangeRateRepository
) {
fun getBudgetLeft(tripId: Int): Double {
fun getBudgetLeft(tripId: Int): Flow<Double?> {
return expenseDao.budgetLeft(tripId)
}
@WorkerThread
suspend fun save(expense: Expense) {
expenseDao.insert(expense)
suspend fun save(expense: Expense): Long {
return expenseDao.insert(expense)
}
@WorkerThread
@@ -49,9 +49,10 @@ class ExpenseRepository @Inject constructor(
categoryIds = categoryIds,
categoriesEmpty = categoryIds.isEmpty(),
startAmount = filter.startAmount,
endAmount = filter.endAmount
endAmount = filter.endAmount,
startDate = if(filter.startDate == LocalDate.MIN) null else filter.startDate.toEpochMilli(),
endDate = if(filter.endDate == LocalDate.MAX) null else filter.endDate.plusDays(1).toEpochMilli(),
)
}
).flow
}
@@ -62,18 +63,18 @@ class ExpenseRepository @Inject constructor(
filter: Filter = Filter()
): Flow<List<ExpenseDto>> {
val categoryIds = filter.categories.map { it.id }
return expenseDao.expenseDto(
tripId = tripId,
search = search.takeIf { it.isNotBlank() },
categoryIds = categoryIds,
categoriesEmpty = categoryIds.isEmpty(),
startAmount = filter.startAmount,
endAmount = filter.endAmount
endAmount = filter.endAmount,
startDate = if(filter.startDate == LocalDate.MIN) null else filter.startDate.toEpochMilli(),
endDate = if(filter.endDate == LocalDate.MAX) null else filter.endDate.plusDays(1).toEpochMilli(),
)
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun recalculateTripExpenses(tripId: Int) {
val expenses = getExpensesDto(tripId).first()
expenses.forEach { expenseDto ->

View File

@@ -2,8 +2,6 @@ package cc.n0th1ng.tripmoney.navigation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -12,16 +10,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -38,13 +32,13 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.screens.listexpense.FilterDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import com.composables.icons.materialsymbols.outlined.R.drawable
@@ -121,7 +115,11 @@ fun TopBar(
) {
Icon(
tint = MaterialTheme.colorScheme.primary,
painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined),
painter = painterResource(
if (filter.isDefault())
drawable.materialsymbols_ic_filter_alt_outlined
else com.composables.icons.materialsymbols.outlinedfilled.R.drawable.materialsymbols_ic_filter_alt_outlined_filled
),
contentDescription = null,
modifier = Modifier.clickable(onClick = {
showFilter = true
@@ -150,59 +148,15 @@ fun TopBar(
showFilter = false
},
categories = categories,
filter = filter
filter = filter,
onClear = {
onFilterChange(Filter())
showFilter = false
}
)
}
}
@Composable
fun FilterDialog(
onDismiss: () -> Unit,
onSave: (Filter) -> Unit,
categories: List<Category>,
filter: Filter
) {
var filter by remember { mutableStateOf(filter) }
var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) }
var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) }
AlertDialog(
onDismiss, {
Button(
enabled = true,
onClick = {
onSave(
filter.withStartAmount(fromAmountString.safeToDouble())
.withEndAmount( toAmountString.safeToDouble())
)
}) { Text(stringResource(R.string.save)) }
}, title = { Text("Filter") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Categories")
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
categories.forEach {
FilterChip(selected = filter.categories.contains(it), onClick = {
filter = if (filter.categories.contains(it)) {
filter.without(it)
} else {
filter.with(it)
}
}, label = { Text(text = it.name) })
}
}
AmountTextField(label = "from", onValueChange = { newText ->
fromAmountString = newText
}, value = fromAmountString)
AmountTextField(label = "to", onValueChange = { newText ->
toAmountString = newText
}, value = toAmountString)
}
})
}
@Composable
fun AmountTextField(label: String, onValueChange: (String) -> Unit, value: String) {
var value by remember { mutableStateOf(value) }
@@ -256,22 +210,3 @@ fun PreviewTopBar() {
)
}
}
@AllPreviews
@Composable
fun PreviewFilterDialog() {
TripMoneyTheme {
FilterDialog(
onDismiss = {},
onSave = {},
categories = categoriesToPreview,
filter = Filter()
)
}
}
private fun String.safeToDouble(): Double {
if(this == "") return Double.MAX_VALUE
if(this.isEmpty()) return 0.0
return this.toDouble()
}

View File

@@ -98,7 +98,8 @@ fun AddExpenseBottomSheet(
expenseDtoToEdit = expenseDtoToEdit,
state = state,
currentTrip = currentTrip ?: Trip.DUMMY,
categories = categories
categories = categories,
onSaveCategory = {expenseAndCategoryViewModel.save(it)}
)
}
@@ -111,14 +112,11 @@ fun AddExpenseBottomSheet(
expenseDtoToEdit: ExpenseDto?,
state: SheetState,
currentTrip: Trip,
categories: List<Category>
categories: List<Category>,
onSaveCategory: (Category) -> Unit
) {
val currentTripId = currentTrip.id
if (categories.isEmpty()) {
return
}
var amount by remember {
mutableStateOf(
"%.2f".format(expenseDtoToEdit?.expense?.amount ?: 0.00)
@@ -138,7 +136,11 @@ fun AddExpenseBottomSheet(
expenseDtoToEdit?.expense?.currency ?: currentTrip.currency
)
}
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
var category by remember {
mutableStateOf(
expenseDtoToEdit?.category ?: if (categories.isEmpty()) null else categories[0]
)
}
var datetime by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now()
@@ -273,14 +275,14 @@ fun AddExpenseBottomSheet(
SaveButton(
modifier = Modifier.fillMaxWidth(),
enabled = enableSave,
enabled = enableSave && category != null,
onClick = {
val expenseToSave = Expense(
amount = equationResult,
currency = currency,
note = note,
datetime = datetime,
categoryId = category.id,
categoryId = category!!.id,
tripId = currentTripId
)
onSave(
@@ -319,7 +321,8 @@ fun AddExpenseBottomSheet(
category = selectedCategory
},
selected = category,
categories = categories
categories = categories,
onSaveCategory = onSaveCategory
)
}
}
@@ -410,7 +413,7 @@ fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Str
}
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
fun CategoryButton(onClick: () -> Unit, category: Category?, modifier: Modifier = Modifier) {
Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick,
@@ -422,25 +425,21 @@ fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier =
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
// Row(modifier = modifier.fillMaxWidth()) {
if (category != null) {
Icon(
tint = Color(category.color.toColorInt()),
modifier = Modifier
.size(30.dp)
// .background(
// color = MaterialTheme.colorScheme.prima,
// shape = MaterialTheme.shapes.small
// )
.padding(end = 10.dp),
painter = painterResource(category.icon.resource),
contentDescription = stringResource(R.string.category),
)
}
Text(
text = category.name,
text = category?.name ?: stringResource(R.string.pick_category),
style = MaterialTheme.typography.titleMedium
)
// }
}
}
@@ -484,12 +483,20 @@ fun NumberKeyboard(
modifier = Modifier
.weight(1f),
containerColor = Color.Transparent,
onLongClick = onLongBackspaceClick
onLongClick = onLongBackspaceClick,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
"+", "/", "-", "*" -> KeyboardButton(
"+", "÷", "", "×" -> KeyboardButton(
text = key,
onClick = { onOperatorClick(key) },
onClick = {
when (key) {
"+" -> onOperatorClick("+")
"÷" -> onOperatorClick("/")
"" -> onOperatorClick("-")
"×" -> onOperatorClick("*")
}
},
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
@@ -500,7 +507,7 @@ fun NumberKeyboard(
onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f),
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSecondary
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -531,8 +538,9 @@ fun KeyboardButton(
) {
when {
text != null -> Text(
text,
style = MaterialTheme.typography.headlineMedium
text = text,
style = MaterialTheme.typography.headlineMedium,
color = contentColor
)
icon != null -> Icon(painter = icon, contentDescription = null)
@@ -541,7 +549,7 @@ fun KeyboardButton(
}
val keyboard = listOf(
listOf("+", "-", "*", "/"),
listOf("+", "", "×", "÷"),
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
@@ -573,7 +581,8 @@ fun PreviewAddExpenseDisabled() {
LocalDate.parse("2020-01-15"),
Currencies.entries.random().name
),
categories = categoriesToPreview
categories = categoriesToPreview,
{}
)
}
@@ -617,7 +626,8 @@ fun PreviewAddExpenseEnabled() {
LocalDate.parse("2020-01-11"),
Currencies.entries.random().name
),
categories = categoriesToPreview
categories = categoriesToPreview,
{}
)
}

View File

@@ -13,46 +13,69 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
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 cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.SearchTextOutlined
import com.composables.icons.materialsymbols.outlined.R
@Composable
fun CategorySelectionDialog(
onDismiss: () -> Unit,
onCategorySelected: (Category) -> Unit,
selected: Category,
selected: Category?,
categories: List<Category>,
onSaveCategory: (Category) -> Unit
) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val listState = rememberLazyListState()
var showAddCategoryDialog by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss, title = { Text(stringResource(string.pick_category)) }, text = {
onDismissRequest = onDismiss,
title = { Text(stringResource(string.pick_category)) },
text = {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if(selected != null) {
listState.animateScrollToItem(categories.indexOfFirst { it == selected })
}
// focusRequester.requestFocus()
}
Column {
var search by remember { mutableStateOf("") }
val filteredCategories = if (search.isBlank()) {
categories
} else {
categories.filter { category ->
category.name.lowercase().contains(search.lowercase())
}
}
LazyColumn(
modifier = Modifier.heightIn(max = 300.dp),
state = listState,
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(
count = categories.size,
key = { index -> categories[index].id }) { index ->
val category = categories[index]
count = filteredCategories.size,
key = { index -> filteredCategories[index].id }) { index ->
val category = filteredCategories[index]
Row(
modifier = Modifier
.fillMaxWidth()
@@ -83,24 +106,41 @@ fun CategorySelectionDialog(
.clickable {
showAddCategoryDialog = true
}
.padding(top = 15.dp),
.padding(bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(R.drawable.materialsymbols_ic_add_outlined),
contentDescription = stringResource(string.category)
)
Text(
text = stringResource(string.add_new), modifier = Modifier.padding(start = 8.dp),
text = stringResource(string.add_new),
modifier = Modifier.padding(start = 8.dp),
)
}
SearchTextOutlined(
text = search,
onTextChange = { newText -> search = newText },
focusRequester = focusRequester
)
}
}, confirmButton = {})
},
confirmButton = {})
if (showAddCategoryDialog) {
AddCategoryDialog(onDismiss = {
showAddCategoryDialog = false
}, onSave = { category ->
expenseAndCategoryViewModel.save(category)
onSaveCategory(category)
showAddCategoryDialog = false
})
}
}
@AllPreviews
@Composable
fun PreviewCategorySelectionDialog() {
TripMoneyTheme {
CategorySelectionDialog(
{}, {},
categoriesToPreview.random(), categoriesToPreview, {})
}
}

View File

@@ -1,20 +1,35 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.SearchTextOutlined
@Composable
fun CurrencySelectionDialog(
@@ -23,29 +38,83 @@ fun CurrencySelectionDialog(
selected: String
) {
AlertDialog(
modifier = Modifier.sizeIn(maxHeight = 500.dp),
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_currency)) },
text = {
Column {
Currencies.names().forEach { currency ->
val scrollState = rememberLazyListState()
val currencies = Currencies.names()
var search by remember { mutableStateOf("") }
LaunchedEffect(selected) {
val index = currencies.indexOf(selected)
if (index != -1) {
scrollState.animateScrollToItem(index)
}
}
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
val filteredCurrencies = if (search.isBlank()) {
currencies
} else {
currencies.filter { currency ->
currency.lowercase().contains(search.lowercase())
}
}
LazyColumn(
state = scrollState,
modifier = Modifier
.weight(1f)
.padding(bottom = 10.dp)
) {
items(
count = filteredCurrencies.size,
key = { index -> filteredCurrencies[index] }
) { index ->
val currency = filteredCurrencies[index]
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onCurrencySelected(currency)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected == currency, onClick = {
onCurrencySelected(currency)
})
selected = selected == currency,
onClick = { onCurrencySelected(currency) }
)
Text(
text = currency, modifier = Modifier.padding(start = 8.dp)
text = currency,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
SearchTextOutlined(
text = search, onTextChange = { newText -> search = newText })
}
},
confirmButton = {})
confirmButton = {},
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = true,
onClick = onDismiss,
) { Text(stringResource(R.string.cancel)) }
}
)
}
@AllPreviews
@Composable
fun PreviewCurrencySelectionDialog() {
TripMoneyTheme {
CurrencySelectionDialog(
{},
{},
Currencies.names().random()
)
}
}

View File

@@ -1,7 +1,6 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
@@ -20,15 +19,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.util.Calendar
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePicker(
@@ -38,8 +37,10 @@ fun DateRangePicker(
onConfirm: (LocalDate, LocalDate) -> Unit
) {
val datePickerState =
rememberDateRangePickerState(initialSelectedStartDateMillis = startDate.toEpochMilli(),
initialSelectedEndDateMillis = endDate.toEpochMilli())
rememberDateRangePickerState(
initialSelectedStartDateMillis = startDate.toEpochMilli(),
initialSelectedEndDateMillis = endDate.toEpochMilli()
)
DatePickerDialog(
onDismissRequest = onDismiss,
@@ -64,25 +65,31 @@ fun DateRangePicker(
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
}
) {
DateRangePicker(state = datePickerState, showModeToggle = false,
DateRangePicker(
state = datePickerState, showModeToggle = false,
title = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePicker(
dateTime: LocalDate = LocalDate.now(),
date: LocalDate = LocalDate.now(),
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit
) {
val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = dateTime.toEpochMilli())
rememberDatePickerState(initialSelectedDateMillis = date.toEpochMilli())
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
Row() {
TextButton(onClick = {
onConfirm(LocalDate.now().minusDays(1))
}) {
Text(stringResource(string.yesterday))
}
TextButton(onClick = {
val selectedMillis = datePickerState.selectedDateMillis
if (selectedMillis != null) {
@@ -94,6 +101,8 @@ fun DatePicker(
}) {
Text("OK")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
@@ -105,11 +114,14 @@ fun DatePicker(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(onDismiss: () -> Unit, onConfirm: (TimePickerState) -> Unit) {
val currentTime = Calendar.getInstance()
fun TimePicker(
onDismiss: () -> Unit,
onConfirm: (TimePickerState) -> Unit,
time: LocalTime = LocalTime.now()
) {
val timePickerState = rememberTimePickerState(
initialHour = currentTime.get(Calendar.HOUR_OF_DAY),
initialMinute = currentTime.get(Calendar.MINUTE),
initialHour = time.hour,
initialMinute = time.minute,
is24Hour = true
)
@@ -129,7 +141,6 @@ fun TimePicker(onDismiss: () -> Unit, onConfirm: (TimePickerState) -> Unit) {
}
@Composable
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
fun DateTimePicker(
dateTime: LocalDateTime = LocalDateTime.now(),
@@ -141,7 +152,9 @@ fun DateTimePicker(
var date by remember { mutableStateOf(dateTime.toLocalDate()) }
if (showDatePicker) {
DatePicker(onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
DatePicker(
date = dateTime.toLocalDate(),
onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
date = newDate
showDatePicker = false
showTimePicker = true
@@ -157,14 +170,20 @@ fun DateTimePicker(
showDatePicker = true
val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute)
onChange(LocalDateTime.of(date, newTime))
})
}, time = dateTime.toLocalTime())
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDateTime.toEpochMilli(): Long =
this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDate.toEpochMilli(): Long =
this.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
@AllPreviews
@Composable
fun DatePickerPreview() {
TripMoneyTheme {
DatePicker(LocalDate.now(), {}, {})
}
}

View File

@@ -0,0 +1,155 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.navigation.AmountTextField
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.pretty
import java.time.LocalDate
@Composable
fun FilterDialog(
onDismiss: () -> Unit,
onSave: (Filter) -> Unit,
onClear: () -> Unit,
categories: List<Category>,
filter: Filter
) {
var filter by remember { mutableStateOf(filter) }
var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) }
var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = true,
onClick = onClear
) { Text(stringResource(R.string.clear)) }
},
confirmButton = {
Button(
enabled = true,
onClick = {
onSave(filter)
}) { Text(stringResource(R.string.save)) }
}, title = { Text("Filter") },
text = {
var showDatePicker by remember { mutableStateOf(false) }
var startDate by remember {
mutableStateOf(filter.startDate)
}
var endDate by remember {
mutableStateOf(filter.endDate)
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Categories")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(7.dp),
modifier = Modifier
.sizeIn(maxHeight = 200.dp)
.verticalScroll(rememberScrollState())
) {
categories.forEach {
FilterChip(selected = filter.categories.contains(it), onClick = {
filter = if (filter.categories.contains(it)) {
filter.without(it)
} else {
filter.with(it)
}
}, label = {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(painterResource(it.icon.resource), contentDescription = null)
Text(text = it.name)
}
})
}
}
Button(
modifier = Modifier
.fillMaxWidth(1f),
shape = MaterialTheme.shapes.medium,
onClick = { showDatePicker = true }) {
val startDateFormatted = startDate.pretty()
val endDateFormatted = endDate.pretty()
Text(
text =
if(startDate == LocalDate.MIN && endDate == LocalDate.MAX) "Show all dates" else
"$startDateFormatted - $endDateFormatted",
fontSize = 17.sp
)
}
AmountTextField(label = "from", onValueChange = { newText ->
fromAmountString = newText
filter = filter.withStartAmount(newText.safeToDouble())
}, value = fromAmountString)
AmountTextField(label = "to", onValueChange = { newText ->
toAmountString = newText
filter = filter.withEndAmount(newText.safeToDouble())
}, value = toAmountString)
}
if (showDatePicker) {
DateRangePicker(
startDate = if(startDate == LocalDate.MIN) LocalDate.now() else startDate,
endDate = if(endDate == LocalDate.MAX) LocalDate.now() else endDate,
onDismiss = { showDatePicker = false },
onConfirm = { newStartDate, newEndDate ->
startDate = newStartDate
endDate = newEndDate
filter = filter.withStartDate(startDate).withEndDate(endDate)
showDatePicker = false
})
}
})
}
@AllPreviews
@Composable
fun PreviewFilterDialog() {
TripMoneyTheme {
FilterDialog(
onDismiss = {},
onSave = {},
categories = categoriesToPreview.plus(categoriesToPreview).plus(categoriesToPreview),
filter = Filter(),
onClear = {}
)
}
}
private fun String.safeToDouble(): Double {
if (this == "") return Double.MAX_VALUE
if (this.isEmpty()) return 0.0
return this.toDouble()
}

View File

@@ -1,10 +1,7 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -22,6 +19,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -40,6 +39,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -49,6 +49,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -79,13 +83,13 @@ import java.time.format.DateTimeFormatter
import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen(
filter: Filter,
search: String,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
onAutoOpenConsumed: () -> Unit,
dateToScroll: String
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
@@ -95,27 +99,38 @@ fun ListExpenseScreen(
val expensesFlow =
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, search, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
var idToScroll by remember { mutableIntStateOf(-1) }
ListExpenseScreen(
currentTrip = currentTrip,
expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onSaveExpense = {
expenseAndCategoryViewModel.save(
it,
currentTrip!!,
onComplete = { id -> idToScroll = id })
},
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate,
initialAutoOpen = initialAutoOpen,
onAutoOpenConsumed = onAutoOpenConsumed
onAutoOpenConsumed = onAutoOpenConsumed,
idToScroll = idToScroll,
dateToScroll = dateToScroll
)
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen(
currentTrip: Trip?,
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
onAutoOpenConsumed: () -> Unit,
idToScroll: Int,
dateToScroll: String
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -134,16 +149,65 @@ fun ListExpenseScreen(
var itemToDelete by remember { mutableStateOf<Expense?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
if (currentTrip != null && !currentTrip.isDummy()) {
ExtendedFloatingActionButton(
onClick = { showBottomSheet = true },
icon = { Icon(Icons.Filled.Add, stringResource(string.add_expense)) },
text = { Text(text = stringResource(string.add_expense)) },
)
}
})
{
Box {
if (items.itemCount == 0) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
val textToShow = if (currentTrip == null || currentTrip.isDummy()) {
stringResource(string.no_trip_picked)
} else {
stringResource(string.no_expenses)
}
Text(
text = textToShow,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
LaunchedEffect(Unit) {
if (dateToScroll == "") return@LaunchedEffect
for (index in 0 until items.itemCount) {
val item = items.peek(index)
if (item is ExpenseListItemUi.Header && item.date.toString() == dateToScroll) {
listState.animateScrollToItem(index)
break
}
}
}
LaunchedEffect(idToScroll) {
if (idToScroll == -1) return@LaunchedEffect
for (index in 0 until items.itemCount) {
val item = items.peek(index)
if (item is ExpenseListItemUi.Item && item.expenseDto.expense.id == idToScroll) {
listState.animateScrollToItem(index)
break
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.semantics {
contentDescription = "expensesList"
},
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
@@ -156,9 +220,7 @@ fun ListExpenseScreen(
}
}
) { index ->
when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
@@ -188,6 +250,8 @@ fun ListExpenseScreen(
}
}
}
if (itemToDelete != null) {
DeleteConfirmationDialog(
onConfirm = {
@@ -215,11 +279,9 @@ fun ListExpenseScreen(
state = sheetState
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
Row(
@@ -262,7 +324,6 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SwipeToDeleteExpenseCard(
expenseDto: ExpenseDto,
@@ -334,26 +395,27 @@ fun DeleteConfirmationDialog(
.fillMaxWidth()
.padding(top = 24.dp)
) {
Text(
text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(end = 24.dp)
.clickable { onCancel() }
)
Text(
text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onConfirm() }
Button(
modifier = Modifier.padding(end = 20.dp),
onClick = onCancel
) {
Text(stringResource(string.cancel))
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(stringResource(string.delete))
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ExpenseCard(
expenseDto: ExpenseDto,
@@ -363,7 +425,7 @@ fun ExpenseCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.fillMaxWidth(0.95f)
.height(70.dp)
.combinedClickable(
enabled = true,
@@ -454,19 +516,73 @@ fun ExpenseCard(
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewListExpenseScreen() {
TripMoneyTheme() {
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
ListExpenseScreen(
currentTrip = Trip(
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01"),
endDate = LocalDate.parse("2026-01-11"),
),
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{}
{},
0,
""
)
}
}
@AllPreviews
@Composable
fun PreviewListExpenseScreenWithoutExpenses() {
TripMoneyTheme() {
val pagingData = PagingData.from(emptyList<ExpenseListItemUi>())
ListExpenseScreen(
currentTrip = Trip(
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01"),
endDate = LocalDate.parse("2026-01-11"),
),
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{},
0,
""
)
}
}
@AllPreviews
@Composable
fun PreviewListExpenseScreenWithoutTrip() {
TripMoneyTheme() {
val pagingData = PagingData.from(emptyList<ExpenseListItemUi>())
ListExpenseScreen(
currentTrip = null,
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{},
0,
""
)
}
@@ -475,7 +591,7 @@ fun PreviewListExpenseScreen() {
@AllPreviews
@Composable
fun PreviewDeleteConfirmationDialog() {
TripMoneyTheme() {
TripMoneyTheme {
DeleteConfirmationDialog(
onConfirm = {},
onCancel = {})
@@ -483,7 +599,6 @@ fun PreviewDeleteConfirmationDialog() {
}
@RequiresApi(Build.VERSION_CODES.O)
private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseListItemUi> {
val sampleCategories = listOf(
Category(

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -48,7 +46,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -63,9 +60,6 @@ import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.collections.emptyList
@RequiresApi(Build.VERSION_CODES.O)
@Composable
@@ -159,7 +153,7 @@ fun ManageCategoriesScreen(
if (itemToDelete != null) {
DeleteConfirmationDialog(
bodyText = stringResource(string.delete_category_info),
bodyText = stringResource(string.delete_category_info).format(itemToDelete?.name),
onConfirm = {
onDeleteCategory(itemToDelete!!)
itemToDelete = null
@@ -236,7 +230,7 @@ fun SwipeToDeleteExpenseCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {

View File

@@ -1,6 +1,5 @@
package cc.n0th1ng.tripmoney.screens.settings
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
@@ -36,34 +35,23 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.statistics.categories
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.saveCsv
import cc.n0th1ng.tripmoney.utils.shareCsv
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.file.Files
@RequiresApi(Build.VERSION_CODES.S)
@Composable
@@ -168,7 +156,7 @@ fun SettingsScreen(
iconResource = R.drawable.materialsymbols_ic_label_outlined
)
SettingsListItem(
onClick = onCategoriesClick,
onClick = {},
stringResource(string.add_expense),
supportingText = stringResource(string.add_expense_settings),
iconResource = R.drawable.materialsymbols_ic_payments_outlined,

View File

@@ -4,16 +4,23 @@ import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
@@ -30,13 +37,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.core.graphics.toColorLong
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.dto.SummaryPerDay
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
@@ -46,10 +58,12 @@ import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen() {
fun StatisticsScreen(navController: NavController) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
@@ -57,23 +71,31 @@ fun StatisticsScreen() {
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId)
.collectAsState(emptyList())
val summaryPerDayList by expenseAndCategoryViewModel.getSummaryPerDay(currentTripId)
.collectAsState(emptyList())
val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId)
.collectAsState(0.0)
val moneyLeft by expenseAndCategoryViewModel.getBudgetLeft(currentTripId).collectAsState(null)
StatisticsScreen(
summaryPerCategoryList,
summaryPerDayList,
summaryAmount,
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name),
expenseAndCategoryViewModel.getBudgetLeft(currentTripId)
moneyLeft,
onDayClicked = {
date -> navController.navigate(Screens.LIST_EXPENSE + "?dateToScroll=$date")
}
)
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen(
summaryPerCategoryList: List<SummaryPerCategory>,
summaryPerDayList: List<SummaryPerDay>,
summaryAmount: Double,
tripCurrency: Currencies,
moneyLeft: Double
moneyLeft: Double?,
onDayClicked: (String) -> Unit
) {
Column(
modifier = Modifier
@@ -83,7 +105,9 @@ fun StatisticsScreen(
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Summary(
Modifier.weight(1f), -1 * summaryAmount, tripCurrency.name,
Modifier.weight(1f),
if (summaryAmount == 0.0) 0.0 else -1 * summaryAmount,
tripCurrency.name,
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
R.drawable.materialsymbols_ic_payment_arrow_down_outlined,
iconColor = MaterialTheme.colorScheme.error
@@ -95,8 +119,11 @@ fun StatisticsScreen(
iconColor = colorResource(cc.n0th1ng.tripmoney.R.color.good_green)
)
}
SummaryPerCategoryCard(summaryPerCategoryList)
SummaryPerCategoryCard(
modifier = Modifier.heightIn(max = 300.dp),
summaryPerCategoryList = summaryPerCategoryList
)
SummaryPerDayCard(modifier = Modifier.height(300.dp), summaryPerDayList = summaryPerDayList.sortedBy { it.day }, onDayClicked = onDayClicked)
}
}
@@ -104,7 +131,7 @@ fun StatisticsScreen(
@Composable
fun Summary(
modifier: Modifier = Modifier,
amount: Double,
amount: Double?,
currency: String,
text: String,
icon: Int,
@@ -117,7 +144,8 @@ fun Summary(
) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
@@ -141,6 +169,7 @@ fun Summary(
}
Text(
if (amount == null) "" else
"%.2f %s".format(amount, currency),
style = MaterialTheme.typography.titleLarge,
)
@@ -149,14 +178,36 @@ fun Summary(
}
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
fun SummaryPerCategoryCard(
summaryPerCategoryList: List<SummaryPerCategory>,
modifier: Modifier = Modifier
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
if (summaryPerCategoryList.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(cc.n0th1ng.tripmoney.R.string.no_expenses_summary),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
Column(
modifier = Modifier.padding(15.dp),
modifier = Modifier
.padding(15.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
summaryPerCategoryList.forEach {
@@ -167,6 +218,49 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SummaryPerDayCard(modifier: Modifier = Modifier, summaryPerDayList: List<SummaryPerDay>, onDayClicked: (String) -> Unit) {
ElevatedCard(
modifier = modifier
.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
if (summaryPerDayList.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(cc.n0th1ng.tripmoney.R.string.no_expenses_summary),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
Row(
modifier = Modifier
.padding(15.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
summaryPerDayList.forEach { it ->
DayCard(
summaryPerDay = it,
onDayClicked = {date -> onDayClicked(date)}
)
}
}
}
}
}
@Composable
@@ -204,7 +298,7 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
) {
Box(
modifier = Modifier
.height(40.dp)
.height(30.dp)
.fillMaxWidth(0.12f + (0.90f - 0.12f) * summaryPerCategory.percent)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary)
@@ -213,7 +307,7 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(11.dp)
.padding(vertical = 5.dp, horizontal = 10.dp)
) {
Text(
"%d%%".format((summaryPerCategory.percent * 100).toInt()),
@@ -227,23 +321,107 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun DayCard(modifier: Modifier = Modifier, summaryPerDay: SummaryPerDay, onDayClicked: (String) -> Unit) {
Column(
modifier = modifier.fillMaxHeight(), verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "%.2f".format(summaryPerDay.amount),
style = MaterialTheme.typography.labelSmall,
fontSize = (MaterialTheme.typography.labelSmall.fontSize.value - 2).sp,
)
val width = 45.dp
Box(
modifier = Modifier
.width(width)
.fillMaxHeight(0.2f + (0.98f - 0.2f) * summaryPerDay.percent)
.clip(RoundedCornerShape(width / 2))
.background(MaterialTheme.colorScheme.primary)
.clickable(onClick = {onDayClicked(summaryPerDay.day.toString())})
.padding(top = 5.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.size(width - 10.dp)
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(width / 2)
)
.padding(vertical = 3.dp),
) {
Text(
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
lineHeight = 10.sp,
color = MaterialTheme.colorScheme.onTertiaryContainer,
text = summaryPerDay.day.format(DateTimeFormatter.ofPattern("dd"))
)
Text(
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Light,
fontSize = (MaterialTheme.typography.labelSmall.fontSize.value - 2).sp,
lineHeight = 10.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onTertiaryContainer,
text = summaryPerDay.day.format(DateTimeFormatter.ofPattern("E"))
)
}
}
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun Preview() {
fun PreviewStatisticScreen() {
TripMoneyTheme {
Scaffold {
StatisticsScreen(
summaryPerCategoryList,
summaryPerDayList,
summaryAmount = 125.24,
Currencies.entries.random(),
432.14
432.14,
{}
)
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewStatisticScreenWithNoData() {
TripMoneyTheme {
Scaffold {
StatisticsScreen(
emptyList(),
emptyList(),
summaryAmount = 0.0,
Currencies.entries.random(),
null,
{}
)
}
}
}
val categories = listOf(
Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()),
Category(name = "Transport", icon = Icons.FLIGHT, color = colors.random()),
@@ -262,3 +440,25 @@ val summaryPerCategoryList = listOf(
SummaryPerCategory(categories[3], 50.0, 0.1f, Currencies.PLN),
SummaryPerCategory(categories[5], 50.0, 0.0001f, Currencies.PLN),
)
@RequiresApi(Build.VERSION_CODES.O)
val summaryPerDayListRaw = listOf(
SummaryPerDay(LocalDate.now(), 50.0, 0f),
SummaryPerDay(LocalDate.now().minusDays(1), 500.23, 0f),
SummaryPerDay(LocalDate.now().minusDays(2), 1560.53, 0f),
SummaryPerDay(LocalDate.now().minusDays(3), 700.32, 0f),
SummaryPerDay(LocalDate.now().minusDays(4), 201.3, 0f),
SummaryPerDay(LocalDate.now().minusDays(5), 2020.64, 0f),
SummaryPerDay(LocalDate.now().minusDays(6), 510.43, 0f),
SummaryPerDay(LocalDate.now().minusDays(7), 3050.12, 0f),
SummaryPerDay(LocalDate.now().minusDays(8), 264.32, 0f),
SummaryPerDay(LocalDate.now().minusDays(9), 3596.64, 0f)
)
@RequiresApi(Build.VERSION_CODES.O)
val highestAmount = summaryPerDayListRaw.maxOf { it.amount }
@RequiresApi(Build.VERSION_CODES.O)
val summaryPerDayList = summaryPerDayListRaw.map {
it.copy(percent = ((it.amount / highestAmount)).toFloat())
}.sortedBy { it.day.toEpochDay() }

View File

@@ -104,7 +104,7 @@ fun AddTripBottomSheet(
var endDate by remember {
mutableStateOf(
tripToEdit?.startDate ?: LocalDate.now()
tripToEdit?.endDate ?: LocalDate.now()
)
}

View File

@@ -38,14 +38,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.paging.PagingData
@@ -113,6 +112,17 @@ fun TripPickerScreen(
Icon(Icons.Filled.Add, stringResource(string.add_trip))
}
}) { paddingValues ->
if (trips.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize().padding(10.dp), contentAlignment = Alignment.Center) {
Text(
text = stringResource(string.no_trip_added),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
LazyColumn(
modifier = Modifier
.padding(horizontal = 15.dp)
@@ -138,6 +148,8 @@ fun TripPickerScreen(
Spacer(Modifier.height(10.dp))
}
}
}
if (showBottomSheet) {
AddTripBottomSheet(
@@ -250,7 +262,8 @@ fun TripCard(
}
Column(
modifier = Modifier.padding(end = 20.dp),
horizontalAlignment = Alignment.End) {
horizontalAlignment = Alignment.End
) {
Text(
trip.currency.uppercase(),
style = MaterialTheme.typography.titleLarge,
@@ -308,3 +321,18 @@ fun PreviewTripPickerScreen() {
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewTripPickerScreenNoTrip() {
TripMoneyTheme {
TripPickerScreen(
tripsFlow = MutableStateFlow(PagingData.from(emptyList())),
currentTripId = 1,
onDelete = {},
onClick = {},
onSave = {}
)
}
}

View File

@@ -1,15 +1,15 @@
package cc.n0th1ng.tripmoney.utils
val colors: List<String> = listOf(
"#af1b3f",
"#083D77",
"#5998c5",
"#f7934c",
"#ec0b43",
"#87A330",
"#6F8AB7",
"#F26CA7",
"#5E4AE3",
"#0B7189",
"#2A7F62",
"#0B7189"
"#5998c5",
"#5E4AE3",
"#6F8AB7",
"#87A330",
"#F26CA7",
"#af1b3f",
"#ec0b43",
"#f7934c"
)

View File

@@ -1,9 +1,169 @@
package cc.n0th1ng.tripmoney.utils
enum class Currencies {
AED,
AFN,
ALL,
AMD,
ANG,
AOA,
ARS,
AUD,
AWG,
AZN,
BAM,
BBD,
BDT,
BHD,
BIF,
BMD,
BND,
BOB,
BRL,
BSD,
BTN,
BWP,
BYN,
BZD,
CAD,
CDF,
CHF,
CLP,
CNH,
CNY,
COP,
CRC,
CUP,
CVE,
CZK,
DJF,
DKK,
DOP,
DZD,
EGP,
ERN,
ETB,
FJD,
FKP,
GBP,
GEL,
GGP,
GHS,
GIP,
GMD,
GNF,
GTQ,
GYD,
HKD,
HNL,
HTG,
HUF,
IDR,
ILS,
IMP,
INR,
IQD,
IRR,
ISK,
JEP,
JMD,
JOD,
JPY,
KES,
KGS,
KHR,
KMF,
KRW,
KWD,
KYD,
KZT,
LAK,
LBP,
LKR,
LRD,
LSL,
LYD,
MAD,
MDL,
MGA,
MKD,
MMK,
MNT,
MOP,
MRO,
MRU,
MUR,
MVR,
MWK,
MXN,
MYR,
MZN,
NAD,
NGN,
NIO,
NOK,
NPR,
NZD,
OMR,
PAB,
PEN,
PGK,
PHP,
PKR,
PLN,
EUR,
USD;
PYG,
QAR,
RON,
RSD,
RUB,
RWF,
SAR,
SBD,
SCR,
SDG,
SEK,
SGD,
SHP,
SLE,
SOS,
SRD,
SSP,
STN,
SVC,
SYP,
SZL,
THB,
TJS,
TMT,
TND,
TOP,
TRY,
TTD,
TWD,
TZS,
UAH,
UGX,
USD,
UYU,
UZS,
VES,
VND,
VUV,
WST,
XAF,
XAG,
XAU,
XCD,
XCG,
XDR,
XOF,
XPD,
XPF,
XPT,
YER,
ZAR,
ZMW,
ZWG;
companion object {
fun default(): Currencies {

View File

@@ -1,11 +1,8 @@
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

@@ -14,5 +14,10 @@ enum class Icons(@DrawableRes val resource: Int) {
COFFEE(R.drawable.materialsymbols_ic_local_cafe_outlined),
GENERAL(R.drawable.materialsymbols_ic_shoppingmode_outlined),
ENTERTAINMENT(R.drawable.materialsymbols_ic_theaters_outlined),
LAUNDRY(R.drawable.materialsymbols_ic_local_laundry_service_outlined)
LAUNDRY(R.drawable.materialsymbols_ic_local_laundry_service_outlined),
INSURANCE(R.drawable.materialsymbols_ic_health_and_safety_outlined),
SIM_DATA(R.drawable.materialsymbols_ic_sim_card_outlined),
CAR_RENTAL(R.drawable.materialsymbols_ic_directions_car_outlined),
FUEL(R.drawable.materialsymbols_ic_local_gas_station_outlined),
TOURS(R.drawable.materialsymbols_ic_tour_outlined)
}

View File

@@ -0,0 +1,45 @@
package cc.n0th1ng.tripmoney.utils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import com.composables.icons.materialsymbols.outlined.R.drawable
@Composable
fun SearchTextOutlined(
modifier: Modifier = Modifier,
text: String,
onTextChange: (String) -> Unit,
focusRequester: FocusRequester = FocusRequester()
) {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = modifier
.fillMaxWidth()
.focusRequester(focusRequester),
trailingIcon = {
if (text.isNotBlank()) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "close",
modifier = Modifier.clickable(true, onClick = { onTextChange("") })
)
} else {
Icon(
painter = painterResource(drawable.materialsymbols_ic_search_outlined),
contentDescription = "search"
)
}
}
)
}

View File

@@ -10,6 +10,7 @@ import androidx.paging.insertSeparators
import androidx.paging.map
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.dto.SummaryPerDay
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
@@ -30,7 +31,6 @@ import org.apache.commons.csv.CSVPrinter
import java.io.File
import java.time.LocalDate
import javax.inject.Inject
import kotlin.collections.mapValues
@HiltViewModel
@@ -41,11 +41,15 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val tripRepo: TripRepository
) : ViewModel() {
fun getBudgetLeft(tripId: Int): Double {
fun getBudgetLeft(tripId: Int): Flow<Double?> {
return expenseRepo.getBudgetLeft(tripId)
}
fun getExpensesDtoPaged(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<PagingData<ExpenseDto>> =
fun getExpensesDtoPaged(
tripId: Int,
search: String = "",
filter: Filter = Filter()
): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O)
@@ -87,18 +91,22 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}.cachedIn(viewModelScope)
}
fun getExpensesDto(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<List<ExpenseDto>> =
fun getExpensesDto(
tripId: Int,
search: String = "",
filter: Filter = Filter()
): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId, search, filter)
@RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) {
fun save(expense: Expense, trip: Trip, onComplete: (Int) -> Unit) {
viewModelScope.launch {
val rate = exchangeRateRepository.getRate(
Currencies.valueOf(expense.currency),
Currencies.valueOf(trip.currency),
expense.datetime.toLocalDate()
)
expenseRepo.save(expense.copy(rate = rate))
val id = expenseRepo.save(expense.copy(rate = rate))
onComplete(id.toInt())
}
}
@@ -124,7 +132,6 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun generateCSVToFile(tripId: Int, file: File) {
file.writer().use { writer ->
CSVPrinter(
@@ -144,7 +151,6 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getDailySums(tripId: Int, search: String, filter: Filter): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId, search, filter)
.map { expenses ->
@@ -155,14 +161,12 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryAmount(tripId: Int): Flow<Double> {
return getExpensesDto(tripId).map { list ->
list.sumOf { it.expense.amount * it.expense.rate }
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategory>> {
val tripFlow = tripRepo.getTrip(tripId)
val expensesFlow = getExpensesDto(tripId)
@@ -185,14 +189,37 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryPerDay(tripId: Int): Flow<List<SummaryPerDay>> {
val tripFlow = tripRepo.getTrip(tripId)
val expensesFlow = getExpensesDto(tripId)
return tripFlow.combine(expensesFlow) { trip, expenses ->
val summaryPerDayRaw = expenses.groupBy { it.expense.datetime.toLocalDate() }
.map { (day, expensesForDay) ->
val total = expensesForDay.sumOf { it.expense.convertedAmount() }
SummaryPerDay(
amount = total,
day = day,
percent = 0.0f
)
}
.sortedByDescending { it.day }
val highestAmount =
if (summaryPerDayRaw.isEmpty()) 1.0 else summaryPerDayRaw.maxOf { it.amount }
summaryPerDayRaw.map {
it.copy(percent = ((it.amount / highestAmount)).toFloat())
}
}
}
fun clearOldRates() {
viewModelScope.launch {
exchangeRateRepository.clearOldRates()
}
}
@RequiresApi(Build.VERSION_CODES.O)
sealed class ExpenseListItemUi {
data class Item(val expenseDto: ExpenseDto) : ExpenseListItemUi()
data class Header(val date: LocalDate, val sum: Double, val currency: String) :

View File

@@ -32,4 +32,17 @@
<string name="export_csv_subttext">Zapisz wydatki z %s do pliku</string>
<string name="add_new_category">Dodaj kategorie</string>
<string name="edit_category">Edytuj kategorie</string>
<string name="archive">Archiwizuj</string>
<string name="you_want_archive">Chcesz zarchiwizować?</string>
<string name="archive_category_info">Żadne wydatki nie będą usunięte.</string>
<string name="delete_category_info">Wszystkie wydatki z kategorii %s zostaną usunięte.</string>
<string name="budget">Budżet</string>
<string name="money_left">Pozostałe środki</string>
<string name="add_expense_settings">Pokaż dodawanie wydatku na starcie</string>
<string name="yesterday">Wczoraj</string>
<string name="clear">Wyczyść</string>
<string name="no_expenses">Zacznij budżetowanie od dodania wydatków</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_expenses_summary">Brak wydatków do podsumowania</string>
</resources>

View File

@@ -35,8 +35,14 @@
<string name="archive">Archive</string>
<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="delete_category_info">Your all expenses with category %s 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>
<string name="yesterday">Yesterday</string>
<string name="clear">Clear</string>
<string name="no_expenses">Start budgeting by adding 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_expenses_summary">No expenses to summarize</string>
</resources>

View File

@@ -1,53 +0,0 @@
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.baselineprofile)
}
android {
namespace = "cc.n0th1ng.baselineprofile"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
defaultConfig {
minSdk = 28
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
}
// This is the configuration block for the Baseline Profile plugin.
// You can specify to run the generators on a managed devices or connected devices.
baselineProfile {
useConnectedDevices = true
}
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.ui.test.junit4)
}
androidComponents {
onVariants { v ->
val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
v.instrumentationRunnerArguments.put(
"targetAppId",
v.testedApks.map { artifactsLoader.load(it)?.applicationId }
)
}
}

View File

@@ -1,36 +0,0 @@
package cc.n0th1ng.baselineprofile
import android.R.attr.contentDescription
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() {
rule.collect(
packageName = "cc.n0th1ng.tripmoney",
includeInStartupProfile = true
) {
pressHome()
startActivityAndWait()
device.waitForIdle()
device.wait(Until.hasObject(By.desc("list screen")), 10_000)
}
}
}

View File

@@ -1,76 +0,0 @@
package cc.n0th1ng.baselineprofile
import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* This test class benchmarks the speed of app startup.
* Run this benchmark to verify how effective a Baseline Profile is.
* It does this by comparing [CompilationMode.None], which represents the app with no Baseline
* Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles.
*
* Run this benchmark to see startup measurements and captured system traces for verifying
* the effectiveness of your Baseline Profiles. You can run it directly from Android
* Studio as an instrumentation test, or run all benchmarks for a variant, for example benchmarkRelease,
* with this Gradle task:
* ```
* ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest
* ```
*
* You should run the benchmarks on a physical device, not an Android emulator, because the
* emulator doesn't represent real world performance and shares system resources with its host.
*
* For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark)
* and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args).
**/
@RunWith(AndroidJUnit4::class)
@LargeTest
class StartupBenchmarks {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun startupCompilationNone() =
benchmark(CompilationMode.None())
@Test
fun startupCompilationBaselineProfiles() =
benchmark(CompilationMode.Partial(BaselineProfileMode.Require))
private fun benchmark(compilationMode: CompilationMode) {
// The application id for the running build variant is read from the instrumentation arguments.
rule.measureRepeated(
packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
?: throw Exception("targetAppId not passed as instrumentation runner arg"),
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = {
pressHome()
},
measureBlock = {
startActivityAndWait()
// TODO Add interactions to wait for when your app is fully drawn.
// The app is fully drawn when Activity.reportFullyDrawn is called.
// For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter
// from the AndroidX Activity library.
// Check the UiAutomator documentation for more information on how to
// interact with the app.
// https://d.android.com/training/testing/other-components/ui-automator
}
)
}
}

View File

@@ -0,0 +1,54 @@
import com.android.build.api.dsl.ManagedVirtualDevice
kotlin {
jvmToolchain(21)
}
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "cc.n0th1ng.benchmark"
compileSdk = 36
defaultConfig {
minSdk = 24
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["androidx.compose.ui.test.tagsAsResourceId"] = "true"
}
testOptions.managedDevices.devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
buildTypes {
create("benchmark") {
isDebuggable = true
signingConfig = getByName("debug").signingConfig
matchingFallbacks += listOf("release")
}
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}
androidComponents {
beforeVariants(selector().all()) {
it.enable = it.buildType == "benchmark"
}
}

View File

@@ -0,0 +1,46 @@
package cc.n0th1ng.benchmark
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4ClassRunner::class)
class BaselineProfileGenerator {
@RequiresApi(Build.VERSION_CODES.P)
@get:Rule
val rule = BaselineProfileRule()
@RequiresApi(Build.VERSION_CODES.P)
@Test
fun startup() = rule.collect(
maxIterations = 1,
packageName = "cc.n0th1ng.tripmoney",
profileBlock = {
device.executeShellCommand(
"rm -rf /data/data/cc.n0th1ng.tripmoney/files/datastore/"
)
startActivityAndWait()
device.wait(Until.hasObject(By.text("Włochy")), 10_000)
device.findObject(By.text("Włochy")).click()
val isVisible = device.wait(Until.hasObject(By.desc("expensesList")), 10_000)
assert(isVisible)
val expensesList = device.findObject(By.desc("expensesList"))
expensesList.setGestureMargin(device.displayWidth / 5)
expensesList.fling(Direction.DOWN)
expensesList.fling(Direction.UP)
expensesList.fling(Direction.DOWN)
expensesList.fling(Direction.UP)
}
)
}

View File

@@ -0,0 +1,43 @@
package cc.n0th1ng.benchmark
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* This is an example startup benchmark.
*
* It navigates to the device's home screen, and launches the default activity.
*
* Before running this benchmark:
* 1) switch your app's active build variant in the Studio (affedaggcts Studio runs only)
* 2) add `<profileable android:shell="true" />` to your app's manifest, within the `<application>` tag
*
* Run this benchmark from Studio to see startup measurements, and captured system traces
* for investigating your app's performance.
*/
@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "cc.n0th1ng.tripmoney",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
device.wait(Until.hasObject(By.pkg("cc.n0th1ng.tripmoney")), 10_000)
device.waitForIdle()
}
}

View File

@@ -1,27 +1,78 @@
[versions]
agp = "8.13.2"
commonsCsv = "1.14.1"
commonsCsvVersion = "1.14.1"
datastorePreferences = "1.2.1"
desugar_jdk_libsVersion = "2.1.5"
hiltAndroid = "2.59.2"
hiltAndroidCompiler = "2.57.1"
hiltNavigationCompose = "1.3.0"
hiltNavigationComposeVersion = "1.3.0"
iconsMaterialSymbolsOutlinedAndroid = "2.2.1"
kotlin = "2.2.21"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
kotlinxSerializationJsonJvm = "1.11.0"
ktorClientCore = "3.4.3"
ktorClientOkhttp = "3.4.3"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
navigationCompose = "2.9.7"
foundationLayout = "1.10.5"
pagingCompose = "3.4.2"
pagingComposeVersion = "3.4.2"
pagingRuntime = "3.4.2"
roomCompiler = "2.8.4"
roomCompilerVersion = "2.8.4"
roomGuava = "2.8.4"
roomKtx = "2.8.4"
roomPaging = "2.8.4"
roomRuntime = "2.8.4"
roomRxjava2 = "2.8.4"
roomRxjava3 = "2.8.4"
roomTesting = "2.8.4"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.2.4"
baselineprofile = "1.2.4"
profileinstaller = "1.3.1"
uiTestJunit4 = "1.10.6"
benchmarkMacroJunit4 = "1.4.1"
baselineprofile = "1.4.1"
profileinstaller = "1.4.1"
uiTestJunit4 = "1.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
#androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
#androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" }
#androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingCompose" }
#androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
#androidx-room-guava = { module = "androidx.room:room-guava", version.ref = "roomCompiler" }
#androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomCompiler" }
#androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "roomCompiler" }
#androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomCompiler" }
#androidx-room-rxjava3 = { module = "androidx.room:room-rxjava3", version.ref = "roomCompiler" }
#androidx-room-rxjava2 = { module = "androidx.room:room-rxjava2", version.ref = "roomCompiler" }
#androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomCompiler" }
#commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commonsCsv" }
#hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
#hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationComposeVersion" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingComposeVersion" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompilerVersion" }
androidx-room-guava = { module = "androidx.room:room-guava", version.ref = "roomGuava" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "roomPaging" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
androidx-room-rxjava2 = { module = "androidx.room:room-rxjava2", version.ref = "roomRxjava2" }
androidx-room-rxjava3 = { module = "androidx.room:room-rxjava3", version.ref = "roomRxjava3" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomTesting" }
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commonsCsvVersion" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" }
icons-material-symbols-outlined-android = { module = "com.composables:icons-material-symbols-outlined-android", version.ref = "iconsMaterialSymbolsOutlinedAndroid" }
icons-material-symbols-outlined-filled-android = { module = "com.composables:icons-material-symbols-outlined-filled-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" }
@@ -41,6 +92,10 @@ androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomato
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" }
kotlinx-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "kotlinxSerializationJsonJvm" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientOkhttp" }
tools-desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libsVersion" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -21,4 +21,4 @@ dependencyResolutionManagement {
rootProject.name = "tripMoney"
include(":app")
include(":baselineprofile")
include(":benchmark")