Compare commits

..

7 Commits

Author SHA1 Message Date
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
30 changed files with 9206 additions and 326 deletions

View File

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

View File

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

View File

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

View File

@@ -12,16 +12,22 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TripMoney"> android:theme="@style/Theme.TripMoney">
<profileable
android:shell="true"
tools:targetApi="29" />
<activity <activity
android:screenOrientation="portrait"
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.TripMoney"> android:theme="@style/Theme.TripMoney">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"
@@ -29,9 +35,8 @@
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/> android:resource="@xml/file_paths" />
</provider> </provider>
</application> </application>
</manifest> </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.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@@ -11,6 +12,7 @@ import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -55,6 +57,7 @@ class MainActivity : ComponentActivity() {
NavigationDrawer() NavigationDrawer()
} }
} }
} }
} }
@@ -77,7 +80,7 @@ fun NavigationDrawer() {
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState() val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) } var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen
ReportDrawnWhen { !categories.isEmpty() }
CustomNavigationDrawer(navController, drawerState) { CustomNavigationDrawer(navController, drawerState) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -150,4 +153,8 @@ data class Filter(
fun without(category: Category): Filter { fun without(category: Category): Filter {
return this.copy(categories = categories - category) return this.copy(categories = categories - category)
} }
fun isDefault(): Boolean {
return this.categories.isEmpty() && startAmount == 0.0 && endAmount == Double.MAX_VALUE
}
} }

View File

@@ -7,6 +7,7 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import cc.n0th1ng.tripmoney.BuildConfig
import cc.n0th1ng.tripmoney.data.dao.CategoryDao import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao 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.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.data.entity.Expense 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.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors import cc.n0th1ng.tripmoney.utils.colors
@@ -26,9 +25,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@@ -36,11 +33,9 @@ import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextInt
@Database( @Database(
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1
version = 1
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class TripDatabase : RoomDatabase() { abstract class TripDatabase : RoomDatabase() {
@@ -62,23 +57,28 @@ object DatabaseModule {
fun provideTripDatabase( fun provideTripDatabase(
@ApplicationContext context: Context @ApplicationContext context: Context
): TripDatabase { ): TripDatabase {
val db: TripDatabase = Room.inMemoryDatabaseBuilder( val builder = if (BuildConfig.DEBUG) Room.inMemoryDatabaseBuilder(
// val db: TripDatabase = Room.databaseBuilder( context = context, klass = TripDatabase::class.java
// name = "tripmoney_db", ) else Room.databaseBuilder(
name = "tripmoney_db",
context = context, context = context,
klass = TripDatabase::class.java, klass = TripDatabase::class.java,
) )
.allowMainThreadQueries() // TODO Remove in production!
.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
.build()
CoroutineScope(Dispatchers.IO).launch { val db: TripDatabase =
DatabasePrepopulator( builder.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
tripDao = db.tripDao(), .build()
categoryDao = db.categoryDao(),
expenseDao = db.expenseDao() if (BuildConfig.DEBUG) {
).prepopulate() CoroutineScope(Dispatchers.IO).launch {
DatabasePrepopulator(
tripDao = db.tripDao(),
categoryDao = db.categoryDao(),
expenseDao = db.expenseDao()
).prepopulate()
}
} }
return db return db
} }
@@ -152,29 +152,46 @@ private class DatabasePrepopulator(
val sampleCategories = listOf( val sampleCategories = listOf(
Category( Category(
name = "Hotel", name = "Hotel", icon = Icons.HOTEL, color = colors.random()
icon = Icons.HOTEL,
color = colors.random()
), ),
Category( Category(
name = "Jedzenie", name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()
icon = Icons.RESTAURANT,
color = colors.random()
), ),
Category( Category(
name = "Transport", name = "Transport", icon = Icons.FLIGHT, color = colors.random()
icon = Icons.FLIGHT,
color = colors.random()
), ),
Category( Category(
name = "Rozrywka", name = "Rozrywka", icon = Icons.ATTRACTION, color = colors.random()
icon = Icons.ATTRACTION,
color = colors.random()
), ),
Category( Category(
name = "Zakupy", name = "Zakupy", icon = Icons.GROCERIES, color = colors.random()
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 +210,14 @@ private class DatabasePrepopulator(
val expense = Expense( val expense = Expense(
categoryId = Random.nextInt(1, 5), categoryId = Random.nextInt(1, sampleCategories.size),
tripId = 1, tripId = 1,
amount = Random.nextDouble(0.1, 300.0), amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name, currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "", note = if (i % 3 == 0) "Some note" else "",
datetime = datetime, datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble( rate = if (Random.nextBoolean()) Random.nextDouble(
0.1, 0.1, 5.0
5.0
) else 1.0 ) else 1.0
) )
expense expense

View File

@@ -4,9 +4,9 @@ import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -18,6 +18,8 @@ interface ExpenseDao {
suspend fun insert(expense: Expense) suspend fun insert(expense: Expense)
@Transaction
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT * FROM expense SELECT * FROM expense
@@ -87,13 +89,17 @@ interface ExpenseDao {
@Query( @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 FROM trip
LEFT JOIN expense ON expense.trip_id = trip.id LEFT JOIN expense ON expense.trip_id = trip.id
WHERE trip.id = :tripId WHERE trip.id = :tripId
""" """
) )
fun budgetLeft(tripId: Int): Double fun budgetLeft(tripId: Int): Flow<Double?>
@Delete @Delete
suspend fun delete(expense: Expense) suspend fun delete(expense: Expense)

View File

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

View File

@@ -20,7 +20,7 @@ class ExpenseRepository @Inject constructor(
private val exchangeRateRepository: ExchangeRateRepository private val exchangeRateRepository: ExchangeRateRepository
) { ) {
fun getBudgetLeft(tripId: Int): Double { fun getBudgetLeft(tripId: Int): Flow<Double?> {
return expenseDao.budgetLeft(tripId) return expenseDao.budgetLeft(tripId)
} }

View File

@@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -21,7 +22,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,7 +38,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.Filter import cc.n0th1ng.tripmoney.Filter
@@ -121,7 +120,11 @@ fun TopBar(
) { ) {
Icon( Icon(
tint = MaterialTheme.colorScheme.primary, 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, contentDescription = null,
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = {
showFilter = true showFilter = true
@@ -150,7 +153,11 @@ fun TopBar(
showFilter = false showFilter = false
}, },
categories = categories, categories = categories,
filter = filter filter = filter,
onClear = {
onFilterChange(Filter())
showFilter = false
}
) )
@@ -161,6 +168,7 @@ fun TopBar(
fun FilterDialog( fun FilterDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Filter) -> Unit, onSave: (Filter) -> Unit,
onClear: () -> Unit,
categories: List<Category>, categories: List<Category>,
filter: Filter filter: Filter
) { ) {
@@ -168,20 +176,28 @@ fun FilterDialog(
var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) } var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) }
var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) } var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) }
AlertDialog( AlertDialog(
onDismiss, { onDismissRequest = onDismiss,
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = true,
onClick = onClear
) { Text(stringResource(R.string.clear)) }
},
confirmButton = {
Button( Button(
enabled = true, enabled = true,
onClick = { onClick = {
onSave( onSave(
filter.withStartAmount(fromAmountString.safeToDouble()) filter.withStartAmount(fromAmountString.safeToDouble())
.withEndAmount( toAmountString.safeToDouble()) .withEndAmount(toAmountString.safeToDouble())
) )
}) { Text(stringResource(R.string.save)) } }) { Text(stringResource(R.string.save)) }
}, title = { Text("Filter") }, }, title = { Text("Filter") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Categories") Text(text = "Categories")
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
categories.forEach { categories.forEach {
FilterChip(selected = filter.categories.contains(it), onClick = { FilterChip(selected = filter.categories.contains(it), onClick = {
filter = if (filter.categories.contains(it)) { filter = if (filter.categories.contains(it)) {
@@ -189,7 +205,12 @@ fun FilterDialog(
} else { } else {
filter.with(it) filter.with(it)
} }
}, label = { Text(text = it.name) }) }, label = {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(painterResource(it.icon.resource), contentDescription = null)
Text(text = it.name)
}
})
} }
} }
AmountTextField(label = "from", onValueChange = { newText -> AmountTextField(label = "from", onValueChange = { newText ->
@@ -265,13 +286,14 @@ fun PreviewFilterDialog() {
onDismiss = {}, onDismiss = {},
onSave = {}, onSave = {},
categories = categoriesToPreview, categories = categoriesToPreview,
filter = Filter() filter = Filter(),
onClear = {}
) )
} }
} }
private fun String.safeToDouble(): Double { private fun String.safeToDouble(): Double {
if(this == "") return Double.MAX_VALUE if (this == "") return Double.MAX_VALUE
if(this.isEmpty()) return 0.0 if (this.isEmpty()) return 0.0
return this.toDouble() return this.toDouble()
} }

View File

@@ -487,7 +487,7 @@ fun NumberKeyboard(
onLongClick = onLongBackspaceClick onLongClick = onLongBackspaceClick
) )
"+", "/", "-", "*" -> KeyboardButton( "+", "÷", "", "×" -> KeyboardButton(
text = key, text = key,
onClick = { onOperatorClick(key) }, onClick = { onOperatorClick(key) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -531,7 +531,7 @@ fun KeyboardButton(
) { ) {
when { when {
text != null -> Text( text != null -> Text(
text, text = text,
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium
) )
@@ -541,7 +541,7 @@ fun KeyboardButton(
} }
val keyboard = listOf( val keyboard = listOf(
listOf("+", "-", "*", "/"), listOf("+", "", "×", "÷"),
listOf("1", "2", "3"), listOf("1", "2", "3"),
listOf("4", "5", "6"), listOf("4", "5", "6"),
listOf("7", "8", "9"), listOf("7", "8", "9"),

View File

@@ -2,6 +2,7 @@ package cc.n0th1ng.tripmoney.screens.listexpense
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
@@ -20,13 +21,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cc.n0th1ng.tripmoney.R.* import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.util.Calendar
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -38,8 +41,10 @@ fun DateRangePicker(
onConfirm: (LocalDate, LocalDate) -> Unit onConfirm: (LocalDate, LocalDate) -> Unit
) { ) {
val datePickerState = val datePickerState =
rememberDateRangePickerState(initialSelectedStartDateMillis = startDate.toEpochMilli(), rememberDateRangePickerState(
initialSelectedEndDateMillis = endDate.toEpochMilli()) initialSelectedStartDateMillis = startDate.toEpochMilli(),
initialSelectedEndDateMillis = endDate.toEpochMilli()
)
DatePickerDialog( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -64,7 +69,8 @@ fun DateRangePicker(
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) } TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
} }
) { ) {
DateRangePicker(state = datePickerState, showModeToggle = false, DateRangePicker(
state = datePickerState, showModeToggle = false,
title = {}) title = {})
} }
} }
@@ -73,27 +79,35 @@ fun DateRangePicker(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DatePicker( fun DatePicker(
dateTime: LocalDate = LocalDate.now(), date: LocalDate = LocalDate.now(),
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit onConfirm: (LocalDate) -> Unit
) { ) {
val datePickerState = val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = dateTime.toEpochMilli()) rememberDatePickerState(initialSelectedDateMillis = date.toEpochMilli())
DatePickerDialog( DatePickerDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { confirmButton = {
TextButton(onClick = { Row() {
val selectedMillis = datePickerState.selectedDateMillis TextButton(onClick = {
if (selectedMillis != null) { onConfirm(LocalDate.now().minusDays(1))
val selectedDate = Instant.ofEpochMilli(selectedMillis) }) {
.atZone(ZoneId.systemDefault()) Text(stringResource(string.yesterday))
.toLocalDate() }
onConfirm(selectedDate) TextButton(onClick = {
val selectedMillis = datePickerState.selectedDateMillis
if (selectedMillis != null) {
val selectedDate = Instant.ofEpochMilli(selectedMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
onConfirm(selectedDate)
}
}) {
Text("OK")
} }
}) {
Text("OK")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) } TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
@@ -103,13 +117,17 @@ fun DatePicker(
} }
} }
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TimePicker(onDismiss: () -> Unit, onConfirm: (TimePickerState) -> Unit) { fun TimePicker(
val currentTime = Calendar.getInstance() onDismiss: () -> Unit,
onConfirm: (TimePickerState) -> Unit,
time: LocalTime = LocalTime.now()
) {
val timePickerState = rememberTimePickerState( val timePickerState = rememberTimePickerState(
initialHour = currentTime.get(Calendar.HOUR_OF_DAY), initialHour = time.hour,
initialMinute = currentTime.get(Calendar.MINUTE), initialMinute = time.minute,
is24Hour = true is24Hour = true
) )
@@ -141,7 +159,9 @@ fun DateTimePicker(
var date by remember { mutableStateOf(dateTime.toLocalDate()) } var date by remember { mutableStateOf(dateTime.toLocalDate()) }
if (showDatePicker) { if (showDatePicker) {
DatePicker(onDismiss = { showDatePicker = false }, onConfirm = { newDate -> DatePicker(
date = dateTime.toLocalDate(),
onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
date = newDate date = newDate
showDatePicker = false showDatePicker = false
showTimePicker = true showTimePicker = true
@@ -157,7 +177,7 @@ fun DateTimePicker(
showDatePicker = true showDatePicker = true
val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute) val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute)
onChange(LocalDateTime.of(date, newTime)) onChange(LocalDateTime.of(date, newTime))
}) }, time = dateTime.toLocalTime())
} }
} }
@@ -167,4 +187,13 @@ fun LocalDateTime.toEpochMilli(): Long =
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun LocalDate.toEpochMilli(): Long = fun LocalDate.toEpochMilli(): Long =
this.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() this.atStartOfDay().atZone(ZoneId.of("UTC")).toInstant().toEpochMilli()
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun DatePickerPreview() {
TripMoneyTheme {
DatePicker(LocalDate.now(), {}, {})
}
}

View File

@@ -47,8 +47,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -143,7 +146,9 @@ fun ListExpenseScreen(
{ {
Box { Box {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().semantics {
contentDescription = "expensesList"
},
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
state = listState state = listState
) { ) {
@@ -363,7 +368,7 @@ fun ExpenseCard(
colors = CardDefaults.elevatedCardColors() colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer), .copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.95f)
.height(70.dp) .height(70.dp)
.combinedClickable( .combinedClickable(
enabled = true, enabled = true,

View File

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

View File

@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -32,7 +34,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.core.graphics.toColorLong
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
@@ -59,11 +60,12 @@ fun StatisticsScreen() {
.collectAsState(emptyList()) .collectAsState(emptyList())
val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId) val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId)
.collectAsState(0.0) .collectAsState(0.0)
val moneyLeft by expenseAndCategoryViewModel.getBudgetLeft(currentTripId).collectAsState(null)
StatisticsScreen( StatisticsScreen(
summaryPerCategoryList, summaryPerCategoryList,
summaryAmount, summaryAmount,
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name), Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name),
expenseAndCategoryViewModel.getBudgetLeft(currentTripId) moneyLeft
) )
} }
@@ -73,7 +75,7 @@ fun StatisticsScreen(
summaryPerCategoryList: List<SummaryPerCategory>, summaryPerCategoryList: List<SummaryPerCategory>,
summaryAmount: Double, summaryAmount: Double,
tripCurrency: Currencies, tripCurrency: Currencies,
moneyLeft: Double moneyLeft: Double?
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -104,7 +106,7 @@ fun StatisticsScreen(
@Composable @Composable
fun Summary( fun Summary(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
amount: Double, amount: Double?,
currency: String, currency: String,
text: String, text: String,
icon: Int, icon: Int,
@@ -117,7 +119,8 @@ fun Summary(
) { ) {
Column( Column(
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
@@ -141,7 +144,8 @@ fun Summary(
} }
Text( Text(
"%.2f %s".format(amount, currency), if (amount == null) "" else
"%.2f %s".format(amount, currency),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
} }
@@ -156,7 +160,10 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer) .copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) { ) {
Column( Column(
modifier = Modifier.padding(15.dp), modifier = Modifier
.padding(15.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(5.dp) verticalArrangement = Arrangement.spacedBy(5.dp)
) { ) {
summaryPerCategoryList.forEach { summaryPerCategoryList.forEach {
@@ -204,7 +211,7 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.height(40.dp) .height(30.dp)
.fillMaxWidth(0.12f + (0.90f - 0.12f) * summaryPerCategory.percent) .fillMaxWidth(0.12f + (0.90f - 0.12f) * summaryPerCategory.percent)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary) .background(MaterialTheme.colorScheme.primary)
@@ -213,7 +220,7 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(11.dp) .padding(vertical = 5.dp, horizontal = 10.dp)
) { ) {
Text( Text(
"%d%%".format((summaryPerCategory.percent * 100).toInt()), "%d%%".format((summaryPerCategory.percent * 100).toInt()),

View File

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

View File

@@ -3,7 +3,8 @@ package cc.n0th1ng.tripmoney.utils
enum class Currencies { enum class Currencies {
PLN, PLN,
EUR, EUR,
USD; USD,
RON;
companion object { companion object {
fun default(): Currencies { fun default(): Currencies {

View File

@@ -30,7 +30,6 @@ import org.apache.commons.csv.CSVPrinter
import java.io.File import java.io.File
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.mapValues
@HiltViewModel @HiltViewModel
@@ -41,7 +40,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val tripRepo: TripRepository private val tripRepo: TripRepository
) : ViewModel() { ) : ViewModel() {
fun getBudgetLeft(tripId: Int): Double { fun getBudgetLeft(tripId: Int): Flow<Double?> {
return expenseRepo.getBudgetLeft(tripId) return expenseRepo.getBudgetLeft(tripId)
} }

View File

@@ -39,4 +39,6 @@
<string name="budget">Budget</string> <string name="budget">Budget</string>
<string name="money_left">Money left</string> <string name="money_left">Money left</string>
<string name="add_expense_settings">Open add expense form on startup</string> <string name="add_expense_settings">Open add expense form on startup</string>
<string name="yesterday">Yesterday</string>
<string name="clear">Clear</string>
</resources> </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] [versions]
agp = "8.13.2" agp = "8.13.2"
commonsCsv = "1.14.1"
commonsCsvVersion = "1.14.1"
datastorePreferences = "1.2.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" iconsMaterialSymbolsOutlinedAndroid = "2.2.1"
kotlin = "2.2.21" kotlin = "2.2.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"
kotlinxSerializationJsonJvm = "1.11.0"
ktorClientCore = "3.4.3"
ktorClientOkhttp = "3.4.3"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
composeBom = "2024.09.00" composeBom = "2024.09.00"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
foundationLayout = "1.10.5" 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" uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.2.4" benchmarkMacroJunit4 = "1.4.1"
baselineprofile = "1.2.4" baselineprofile = "1.4.1"
profileinstaller = "1.3.1" profileinstaller = "1.4.1"
uiTestJunit4 = "1.10.6" uiTestJunit4 = "1.11.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } 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-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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

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