Expense Manager with AI Integrated Thesis File
Expense Manager with AI Integrated Thesis File
Goodbye
Money
An AI-Powered Expense Manager Using Jetpack
Compose & Realm Database
1.1 Background
Managing personal finances is an essential part of everyday life, yet many individuals
struggle to track their expenses effectively. Studies show that over 65% of people
do not maintain a budget, leading to poor financial decisions, unnecessary spending,
and financial stress. While digital tools have simplified financial management, most
existing expense tracking applications lack AI-driven insights and seamless
offline performance.
This project aims to bridge these gaps by developing GoodBye Money, a modern
AI-powered expense tracker that combines intelligent financial insights with fast,
efficient offline storage.
However, the following features are not included in the current version but may be
added in future releases:
❌ Cloud synchronization across multiple devices.
❌ Multi-user support.
❌ Predictive expense forecasting based on past spending trends.
Chapter 2: Literature Review
(This chapter reviews existing research, technologies, and frameworks related to
expense management, AI integration, and mobile app development. It provides a
foundation for understanding the need for an AI-powered, offline-first expense
manager.)
2.1 Introduction
Expense management is an essential aspect of personal finance. Many individuals
struggle to track their expenses, leading to poor budgeting and financial instability.
Over the years, various technologies and methodologies have been developed to
automate and simplify expense tracking.
The findings highlight current gaps in existing solutions and justify the need for
GoodBye Money, an AI-powered expense manager with offline-first capabilities.
Attach Image: A graph comparing user satisfaction of various finance apps based
on offline accessibility, AI insights, and ease of use.
2.4 The Role of Artificial Intelligence in Finance
2.4 The Role of Artificial Intelligence in Finance
Limitation: While AI offers intelligent insights, most expense tracking apps lack
real-time AI-based analysis, creating an opportunity for innovation.
Most finance apps require constant internet access, which creates problems for users
in areas with poor connectivity.
Limitations of XML-Based UI
Conclusion: MVVM ensures scalability and maintainability, making it the best fit
for GoodBye Money.
2.8 Summary of Literature Review
This chapter reviewed:
3.1 Introduction
This chapter details the analysis and design of GoodBye Money, covering:
ID Requirement Description
FR1 User Registration Users can sign up and manage profiles.
FR2 Expense Entry Users can add, edit, and delete expenses.
FR3 AI Insights AI provides financial insights and recommendations.
FR4 Report Generation Users can generate spending reports with charts.
FR5 Offline Mode App works without internet using Realm Database.
3.3 Non-Functional Requirements
Definition: Non-functional requirements define system performance, security, and
scalability.
Actors:
User (primary actor) – Adds expenses, views reports, interacts with AI.
AI System – Processes spending data and provides insights.
Realm Database – Stores financial records.
.
3.8 Summary of System Analysis and Design
This chapter outlined:
com.matchmatrix.goodbyemoney/
│── components/
│ ├── charts/ # Graphs for reports
│ ├── expensesList/ # UI components for expenses
│ ├── CategoryBadge.kt
│ ├── ExpenseRow.kt
│ ├── ExpensesDayGroup.kt
│ ├── PickerTrigger.kt
│ ├── ReportPage.kt
│ ├── TableRow.kt
│ ├── UnstyledTextField.kt
│── mock/ # Test/mock data
│── models/ # Data models
│── network/ # API calls (Retrofit)
│── pages/ # Screens
│── ui.theme/ # App theme
│── utils/ # Helper functions
│── viewmodels/ # ViewModels (MVVM)
│── db.kt # Database logic (Realm)
│── MainActivity.kt # App entry point
import androidx.compose.ui.graphics.Color
import io.realm.kotlin.types.ObjectId
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
constructor(
name: String,
color: Color
) : this() {
this.name = name
this._colorValue = "${color.red},${color.green},${color.blue}"
}
}
import io.realm.kotlin.types.ObjectId
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
import java.time.LocalDate
import java.time.LocalDateTime
constructor(
amount: Double,
recurrence: Recurrence,
date: LocalDateTime,
note: String,
category: Category,
) : this() {
this.amount = amount
this._recurrenceName = recurrence.name
this._dateValue = date.toString()
this.note = note
this.category = category
}
}
if (dataMap[date] == null) {
dataMap[date] = DayExpenses(
expenses = mutableListOf(),
total = 0.0
)
}
dataMap[date]!!.expenses.add(expense)
dataMap[date]!!.total = dataMap[date]!!.total.plus(expense.amount)
}
return dataMap.toSortedMap(compareByDescending { it })
}
if (dataMap[dayOfWeek.name] == null) {
dataMap[dayOfWeek.name] = DayExpenses(
expenses = mutableListOf(),
total = 0.0
)
}
dataMap[dayOfWeek.name]!!.expenses.add(expense)
dataMap[dayOfWeek.name]!!.total =
dataMap[dayOfWeek.name]!!.total.plus(expense.amount)
}
return dataMap.toSortedMap(compareByDescending { it })
}
if (dataMap[dayOfMonth] == null) {
dataMap[dayOfMonth] = DayExpenses(
expenses = mutableListOf(),
total = 0.0
)
}
dataMap[dayOfMonth]!!.expenses.add(expense)
dataMap[dayOfMonth]!!.total =
dataMap[dayOfMonth]!!.total.plus(expense.amount)
}
return dataMap.toSortedMap(compareByDescending { it })
}
if (dataMap[month.name] == null) {
dataMap[month.name] = DayExpenses(
expenses = mutableListOf(),
total = 0.0
)
}
dataMap[month.name]!!.expenses.add(expense)
dataMap[month.name]!!.total =
dataMap[month.name]!!.total.plus(expense.amount)
}
return dataMap.toSortedMap(compareByDescending { it })
}
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.matechmatrix.goodbyemoney.models.Category
import com.matechmatrix.goodbyemoney.ui.theme.Shapes
import com.matechmatrix.goodbyemoney.ui.theme.Typography
@Composable
fun CategoryBadge(category: Category, modifier: Modifier = Modifier) {
Surface(
shape = Shapes.large,
color = category.color.copy(alpha = 0.25f),
modifier = modifier,
) {
Text(
category.name,
color = category.color,
style = Typography.bodySmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
4.3.4 PickerTrigger(PickerTrigger.kt)
package com.matechmatrix.goodbyemoney.components
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.matechmatrix.goodbyemoney.R
import com.matechmatrix.goodbyemoney.ui.theme.FillTertiary
import com.matechmatrix.goodbyemoney.ui.theme.GoodbyeMoneyTheme
import com.matechmatrix.goodbyemoney.ui.theme.Shapes
import com.matechmatrix.goodbyemoney.ui.theme.Typography
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PickerTrigger(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
shape = Shapes.medium,
color = FillTertiary,
modifier = modifier,
onClick = onClick,
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = Typography.titleSmall)
Icon(
painterResource(R.drawable.ic_unfold_more),
contentDescription = "Open picker",
modifier = Modifier.padding(start = 10.dp)
)
}
}
}
4.3.5 ReportPage(ReportPage.kt)
package com.matechmatrix.goodbyemoney.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.matechmatrix.goodbyemoney.components.charts.MonthlyChart
import com.matechmatrix.goodbyemoney.components.charts.WeeklyChart
import com.matechmatrix.goodbyemoney.components.charts.YearlyChart
import com.matechmatrix.goodbyemoney.components.expensesList.ExpensesList
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.ui.theme.LabelSecondary
import com.matechmatrix.goodbyemoney.ui.theme.Typography
import com.matechmatrix.goodbyemoney.utils.formatDay
import com.matechmatrix.goodbyemoney.utils.formatDayForRange
import com.matechmatrix.goodbyemoney.viewmodels.ReportPageViewModel
import com.matechmatrix.goodbyemoney.viewmodels.viewModelFactory
import java.text.DecimalFormat
import java.time.LocalDate
@Composable
fun ReportPage(
innerPadding: PaddingValues,
page: Int,
recurrence: Recurrence,
vm: ReportPageViewModel = viewModel(
key = "$page-${recurrence.name}",
factory = viewModelFactory {
ReportPageViewModel(page, recurrence)
})
) {
val uiState = vm.uiState.collectAsState().value
Column(
modifier = Modifier
.padding(innerPadding)
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column {
Text(
"${
uiState.dateStart.formatDayForRange()
} - ${uiState.dateEnd.formatDayForRange()}",
style = Typography.titleSmall
)
Row(modifier = Modifier.padding(top = 4.dp)) {
Text(
"Rs",
style = Typography.bodyMedium,
color = LabelSecondary,
modifier = Modifier.padding(end = 4.dp)
)
Text(DecimalFormat("0.#").format(uiState.totalInRange), style =
Typography.headlineMedium)
}
}
Column(horizontalAlignment = Alignment.End) {
Text("Avg/day", style = Typography.titleSmall)
Row(modifier = Modifier.padding(top = 4.dp)) {
Text(
"Rs",
style = Typography.bodyMedium,
color = LabelSecondary,
modifier = Modifier.padding(end = 4.dp)
)
Text(DecimalFormat("0.#").format(uiState.avgPerDay), style =
Typography.headlineMedium)
}
}
}
Box(
modifier = Modifier
.height(180.dp)
.padding(vertical = 16.dp)
) {
when (recurrence) {
Recurrence.Weekly -> WeeklyChart(expenses = uiState.expenses)
Recurrence.Monthly -> MonthlyChart(
expenses = uiState.expenses,
LocalDate.now()
)
Recurrence.Yearly -> YearlyChart(expenses = uiState.expenses)
else -> Unit
}
}
ExpensesList(
expenses = uiState.expenses, modifier = Modifier
.weight(1f)
.verticalScroll(
rememberScrollState()
)
)
}
}
4.3.6 TableRow(TableRow.kt)
package com.matechmatrix.goodbyemoney.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.matechmatrix.goodbyemoney.R
import com.matechmatrix.goodbyemoney.ui.theme.Destructive
import com.matechmatrix.goodbyemoney.ui.theme.TextPrimary
import com.matechmatrix.goodbyemoney.ui.theme.Typography
@Composable
fun TableRow(
modifier: Modifier = Modifier,
label: String? = null,
hasArrow: Boolean = false,
isDestructive: Boolean = false,
detailContent: (@Composable RowScope.() -> Unit)? = null,
content: (@Composable RowScope.() -> Unit)? = null
) {
val textColor = if (isDestructive) Destructive else TextPrimary
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (label != null) {
Text(
text = label,
style = Typography.bodyMedium,
color = textColor,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
if (content != null) {
content()
}
if (hasArrow) {
Icon(
painterResource(id = R.drawable.chevron_right),
contentDescription = "Right arrow",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
if (detailContent != null) {
detailContent()
}
}
}
4.3.7 UnstyledTextField(UnstyledTextField.kt)
package com.matechmatrix.goodbyemoney.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.matechmatrix.goodbyemoney.ui.theme.Primary
import com.matechmatrix.goodbyemoney.ui.theme.TextPrimary
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnstyledTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
arrangement: Arrangement.Horizontal = Arrangement.Start,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember
{ MutableInteractionSource() },
shape: Shape = TextFieldDefaults.filledShape,
colors: TextFieldColors = TextFieldDefaults.textFieldColors()
) {
// If color is not provided via the text style, use content color as a default
val textColor = TextPrimary
val mergedTextStyle =
textStyle.merge(TextStyle(color = textColor))
BasicTextField(value = value,
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(Primary),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = singleLine,
maxLines = maxLines,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = value,
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = arrangement,
verticalAlignment = CenterVertically
) {
placeholder?.invoke()
}
},
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 16.dp),
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent,
textColor = TextPrimary,
cursorColor = Primary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
})
}
4.3.8 ExpensesList(expenseList/ExpensesList.kt)
package com.matechmatrix.goodbyemoney.components.expensesList
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.matechmatrix.goodbyemoney.components.ExpensesDayGroup
import com.matechmatrix.goodbyemoney.mock.mockExpenses
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.models.groupedByDay
import com.matechmatrix.goodbyemoney.ui.theme.GoodbyeMoneyTheme
@Composable
fun ExpensesList(expenses: List<Expense>, modifier: Modifier = Modifier) {
val groupedExpenses = expenses.groupedByDay()
Column(modifier = modifier) {
if (groupedExpenses.isEmpty()) {
Text("No data for selected date range.", modifier = Modifier.padding(top
= 32.dp))
} else {
groupedExpenses.keys.forEach { date ->
if (groupedExpenses[date] != null) {
ExpensesDayGroup(
date = date,
dayExpenses = groupedExpenses[date]!!,
modifier = Modifier.padding(top = 24.dp)
)
}
}
}
}
}
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun Preview() {
GoodbyeMoneyTheme {
ExpensesList(mockExpenses)
}
}
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.github.tehras.charts.bar.BarChartData
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.ui.theme.SystemGray04
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import com.github.tehras.charts.piechart.utils.toLegacyInt
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.ui.theme.LabelSecondary
4.4.3 MonthlyChart(charts/MonthlyChart.kt)
package com.matechmatrix.goodbyemoney.components.charts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.bar.BarChart
import com.github.tehras.charts.bar.BarChartData
import com.github.tehras.charts.bar.renderer.xaxis.SimpleXAxisDrawer
import com.github.tehras.charts.bar.renderer.yaxis.SimpleYAxisDrawer
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.models.groupedByDayOfMonth
import com.matechmatrix.goodbyemoney.ui.theme.LabelSecondary
import com.matechmatrix.goodbyemoney.utils.simplifyNumber
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.YearMonth
@Composable
fun MonthlyChart(expenses: List<Expense>, month: LocalDate) {
val groupedExpenses = expenses.groupedByDayOfMonth()
val numberOfDays = YearMonth.of(month.year, month.month).lengthOfMonth()
BarChart(
barChartData = BarChartData(
bars = buildList() {
for (i in 1..numberOfDays) {
add(BarChartData.Bar(
label = "$i",
value = groupedExpenses[i]?.total?.toFloat()
?: 0f,
color = Color.White,
))
}
}
),
labelDrawer = LabelDrawer(recurrence = Recurrence.Monthly, lastDay =
numberOfDays),
yAxisDrawer = SimpleYAxisDrawer(
labelTextColor = LabelSecondary,
labelValueFormatter = ::simplifyNumber,
labelRatio = 7,
labelTextSize = 14.sp
),
barDrawer = BarDrawer(recurrence = Recurrence.Monthly),
modifier = Modifier
.padding(bottom = 20.dp)
.fillMaxSize()
)
}
4.4.4 WeeklyChart (charts/WeeklyChart.kt)
package com.matechmatrix.goodbyemoney.components.charts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.bar.BarChart
import com.github.tehras.charts.bar.BarChartData
import com.github.tehras.charts.bar.BarChartData.Bar
import com.github.tehras.charts.bar.renderer.bar.SimpleBarDrawer
import com.github.tehras.charts.bar.renderer.label.SimpleValueDrawer
import com.github.tehras.charts.bar.renderer.xaxis.SimpleXAxisDrawer
import com.github.tehras.charts.bar.renderer.yaxis.SimpleYAxisDrawer
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.models.groupedByDayOfWeek
import com.matechmatrix.goodbyemoney.ui.theme.LabelSecondary
import com.matechmatrix.goodbyemoney.utils.simplifyNumber
import java.time.DayOfWeek
@Composable
fun WeeklyChart(expenses: List<Expense>) {
val groupedExpenses = expenses.groupedByDayOfWeek()
BarChart(
barChartData = BarChartData(
bars = listOf(
Bar(
label = DayOfWeek.MONDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.MONDAY.name]?.total?.toFloat()
?: 0f,
color = Color.White,
),
Bar(
label = DayOfWeek.TUESDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.TUESDAY.name]?.total?.toFloat() ?:
0f,
color = Color.White
),
Bar(
label = DayOfWeek.WEDNESDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.WEDNESDAY.name]?.total?.toFloat() ?:
0f,
color = Color.White
),
Bar(
label = DayOfWeek.THURSDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.THURSDAY.name]?.total?.toFloat() ?:
0f,
color = Color.White
),
Bar(
label = DayOfWeek.FRIDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.FRIDAY.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
Bar(
label = DayOfWeek.SATURDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.SATURDAY.name]?.total?.toFloat() ?:
0f,
color = Color.White
),
Bar(
label = DayOfWeek.SUNDAY.name.substring(0, 1),
value = groupedExpenses[DayOfWeek.SUNDAY.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
)
),
labelDrawer = LabelDrawer(recurrence = Recurrence.Weekly),
yAxisDrawer = SimpleYAxisDrawer(
labelTextColor = LabelSecondary,
labelValueFormatter = ::simplifyNumber,
labelRatio = 7,
labelTextSize = 14.sp
),
barDrawer = BarDrawer(recurrence = Recurrence.Weekly),
modifier = Modifier
.padding(bottom = 20.dp)
.fillMaxSize()
)
}
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.bar.BarChart
import com.github.tehras.charts.bar.BarChartData
import com.github.tehras.charts.bar.renderer.yaxis.SimpleYAxisDrawer
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.models.groupedByDayOfWeek
import com.matechmatrix.goodbyemoney.models.groupedByMonth
import com.matechmatrix.goodbyemoney.ui.theme.LabelSecondary
import com.matechmatrix.goodbyemoney.utils.simplifyNumber
import java.time.DayOfWeek
import java.time.Month
@Composable
fun YearlyChart(expenses: List<Expense>) {
val groupedExpenses = expenses.groupedByMonth()
BarChart(
barChartData = BarChartData(
bars = listOf(
BarChartData.Bar(
label = Month.JANUARY.name.substring(0, 1),
value = groupedExpenses[Month.JANUARY.name]?.total?.toFloat()
?: 0f,
color = Color.White,
),
BarChartData.Bar(
label = Month.FEBRUARY.name.substring(0, 1),
value = groupedExpenses[Month.FEBRUARY.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.MARCH.name.substring(0, 1),
value = groupedExpenses[Month.MARCH.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.APRIL.name.substring(0, 1),
value = groupedExpenses[Month.APRIL.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.MAY.name.substring(0, 1),
value = groupedExpenses[Month.MAY.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.JUNE.name.substring(0, 1),
value = groupedExpenses[Month.JUNE.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.JULY.name.substring(0, 1),
value = groupedExpenses[Month.JULY.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.AUGUST.name.substring(0, 1),
value = groupedExpenses[Month.AUGUST.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.SEPTEMBER.name.substring(0, 1),
value = groupedExpenses[Month.SEPTEMBER.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.OCTOBER.name.substring(0, 1),
value = groupedExpenses[Month.OCTOBER.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.NOVEMBER.name.substring(0, 1),
value = groupedExpenses[Month.NOVEMBER.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
BarChartData.Bar(
label = Month.DECEMBER.name.substring(0, 1),
value = groupedExpenses[Month.DECEMBER.name]?.total?.toFloat() ?: 0f,
color = Color.White
),
)
),
labelDrawer = LabelDrawer(recurrence = Recurrence.Yearly),
yAxisDrawer = SimpleYAxisDrawer(
labelTextColor = LabelSecondary,
labelValueFormatter = ::simplifyNumber,
labelRatio = 7,
labelTextSize = 14.sp
),
barDrawer = BarDrawer(recurrence = Recurrence.Yearly),
modifier = Modifier
.padding(bottom = 20.dp)
.fillMaxSize()
)
}
4.5 API Integration (network/)
4.5.1 Setting Up Retrofit
package com.matechmatrix.goodbyemoney.network
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitInstance {
private const val BASE_URL = "https://api.openai.com/"
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
val reply = body.choices.firstOrNull()?.message?.content
if (reply != null) {
_messages.add(Message(role = "assistant", content = reply))
onResponse(reply)
} else {
onResponse("The response contained no valid reply.")
}
} else {
onResponse("Response body is null.")
}
} else {
onResponse("Request failed: ${response.code()}
${response.message()} \n${response.errorBody()?.string()}")
}
} catch (e: Exception) {
onResponse("Error: ${e.message}")
}
}
}
if (amount.isEmpty()) {
parsed = 0.0
}
if (parsed != null) {
_uiState.update { currentState ->
currentState.copy(
amount = amount.trim().ifEmpty { "0" },
)
}
}
}
fun submitExpense() {
if (_uiState.value.category != null) {
viewModelScope.launch(Dispatchers.IO) {
val now = LocalDateTime.now()
db.write {
this.copyToRealm(
Expense(
_uiState.value.amount.toDouble(),
_uiState.value.recurrence,
_uiState.value.date.atTime(now.hour, now.minute,
now.second),
_uiState.value.note,
this.query<Category>("_id == $0",
_uiState.value.category!!._id)
.find().first(),
)
)
}
_uiState.update { currentState ->
currentState.copy(
amount = "",
recurrence = Recurrence.None,
date = LocalDate.now(),
note = "",
category = null,
categories = null
)
}
}
}
}
}
4.6.2 AssitantViewModel
package com.matechmatrix.goodbyemoney.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matechmatrix.goodbyemoney.network.AIRequest
import com.matechmatrix.goodbyemoney.network.Message
import com.matechmatrix.goodbyemoney.network.RetrofitInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
val reply =
body.choices.firstOrNull()?.message?.content
if (reply != null) {
_messages.add(Message(role = "assistant",
content = reply))
onResponse(reply)
} else {
onResponse("The response contained no valid
reply.")
}
} else {
onResponse("Response body is null.")
}
} else {
onResponse("Request failed: ${response.code()}
${response.message()} \n${response.errorBody()?.string()}")
}
} catch (e: Exception) {
onResponse("Error: ${e.message}")
}
}
}
}
4.6.3 CategoriesViewModel
package com.matechmatrix.goodbyemoney.viewmodels
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matechmatrix.goodbyemoney.db
import com.matechmatrix.goodbyemoney.models.Category
import io.realm.kotlin.ext.query
import io.realm.kotlin.query.RealmResults
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
init {
_uiState.update { currentState ->
currentState.copy(
categories = db.query<Category>().find()
)
}
viewModelScope.launch(Dispatchers.IO) {
db.query<Category>().asFlow().collect { changes ->
_uiState.update { currentState ->
currentState.copy(
categories = changes.list
)
}
}
}
}
fun showColorPicker() {
_uiState.update { currentState ->
currentState.copy(
colorPickerShowing = true
)
}
}
fun hideColorPicker() {
_uiState.update { currentState ->
currentState.copy(
colorPickerShowing = false
)
}
}
fun createNewCategory() {
viewModelScope.launch(Dispatchers.IO) {
db.write {
this.copyToRealm(Category(
_uiState.value.newCategoryName,
_uiState.value.newCategoryColor
))
}
_uiState.update { currentState ->
currentState.copy(
newCategoryColor = Color.White,
newCategoryName = ""
)
}
}
}
4.6.4 ExpensesViewModel
package com.matechmatrix.goodbyemoney.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matechmatrix.goodbyemoney.db
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.utils.calculateDateRange
import io.realm.kotlin.ext.query
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
init {
_uiState.update { currentState ->
currentState.copy(
expenses = db.query<Expense>().find()
)
}
viewModelScope.launch(Dispatchers.IO) {
setRecurrence(Recurrence.Daily)
}
}
4.6.5 Factory
package com.matechmatrix.goodbyemoney.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
4.6.6 ReportPageViewModel
package com.matechmatrix.goodbyemoney.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matechmatrix.goodbyemoney.db
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.utils.calculateDateRange
import io.realm.kotlin.ext.query
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.LocalTime
init {
viewModelScope.launch(Dispatchers.IO) {
val (start, end, daysInRange) = calculateDateRange(recurrence, page)
viewModelScope.launch(Dispatchers.Main) {
_uiState.update { currentState ->
currentState.copy(
dateStart = LocalDateTime.of(start, LocalTime.MIN),
dateEnd = LocalDateTime.of(end, LocalTime.MAX),
expenses = filteredExpenses,
avgPerDay = avgPerDay,
totalInRange = totalExpensesAmount
)
}
}
}
}
}
4.6.7 ReportViewModel
package com.matechmatrix.goodbyemoney.viewmodels
import androidx.lifecycle.ViewModel
import com.matechmatrix.goodbyemoney.models.Recurrence
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
fun openRecurrenceMenu() {
_uiState.update { currentState ->
currentState.copy(
recurrenceMenuOpened = true
)
}
}
fun closeRecurrenceMenu() {
_uiState.update { currentState ->
currentState.copy(
recurrenceMenuOpened = false
)
}
}
}
4.7 Screens Implementation (pages/)
4.7.1 Add Screen (pages/add.kt)
package com.matechmatrix.goodbyemoney.pages
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import
com.marosseleng.compose.material3.datetimepickers.date.ui.dialog.DatePickerD
ialog
import com.matechmatrix.goodbyemoney.components.TableRow
import com.matechmatrix.goodbyemoney.components.UnstyledTextField
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.ui.theme.*
import com.matechmatrix.goodbyemoney.viewmodels.AddViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun Add(navController: NavController, vm: AddViewModel = viewModel()) {
val state by vm.uiState.collectAsState()
Scaffold(topBar = {
MediumTopAppBar(
title = { Text("Add") },
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = TopAppBarBackground
)
)
}, content = { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier
.padding(16.dp)
.clip(Shapes.large)
.background(BackgroundElevated)
.fillMaxWidth()
) {
TableRow(label = "Amount", detailContent = {
UnstyledTextField(
value = state.amount,
onValueChange = vm::setAmount,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("0") },
arrangement = Arrangement.End,
maxLines = 1,
textStyle = TextStyle(
textAlign = TextAlign.Right,
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
)
)
})
Divider(
modifier = Modifier.padding(start = 16.dp),
thickness = 1.dp,
color = DividerColor
)
TableRow(label = "Recurrence", detailContent = {
var recurrenceMenuOpened by remember {
mutableStateOf(false)
}
TextButton(
onClick = { recurrenceMenuOpened = true }, shape = Shapes.large
) {
Text(state.recurrence?.name ?: Recurrence.None.name)
DropdownMenu(expanded = recurrenceMenuOpened,
onDismissRequest = { recurrenceMenuOpened = false }) {
recurrences.forEach { recurrence ->
DropdownMenuItem(text = { Text(recurrence.name) }, onClick = {
vm.setRecurrence(recurrence)
recurrenceMenuOpened = false
})
}
}
}
})
Divider(
modifier = Modifier.padding(start = 16.dp),
thickness = 1.dp,
color = DividerColor
)
var datePickerShowing by remember {
mutableStateOf(false)
}
TableRow(label = "Date", detailContent = {
TextButton(onClick = { datePickerShowing = true }) {
Text(state.date.toString())
}
if (datePickerShowing) {
DatePickerDialog(onDismissRequest = { datePickerShowing = false },
onDateChange = { it ->
vm.setDate(it)
datePickerShowing = false
},
initialDate = state.date,
title = { Text("Select date", style = Typography.titleLarge) })
}
})
Divider(
modifier = Modifier.padding(start = 16.dp),
thickness = 1.dp,
color = DividerColor
)
TableRow(label = "Note", detailContent = {
UnstyledTextField(
value = state.note,
placeholder = { Text("Leave some notes") },
arrangement = Arrangement.End,
onValueChange = vm::setNote,
modifier = Modifier.fillMaxWidth(),
textStyle = TextStyle(
textAlign = TextAlign.Right,
),
)
})
Divider(
modifier = Modifier.padding(start = 16.dp),
thickness = 1.dp,
color = DividerColor
)
TableRow(label = "Category", detailContent = {
var categoriesMenuOpened by remember {
mutableStateOf(false)
}
TextButton(
onClick = { categoriesMenuOpened = true }, shape = Shapes.large
) {
Text(
state.category?.name ?: "Select a category first",
color = state.category?.color ?: Color.White
)
DropdownMenu(expanded = categoriesMenuOpened,
onDismissRequest = { categoriesMenuOpened = false }) {
state.categories?.forEach { category ->
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(10.dp),
shape = CircleShape,
color = category.color
) {}
Text(
category.name, modifier = Modifier.padding(start = 8.dp)
)
}
}, onClick = {
vm.setCategory(category)
categoriesMenuOpened = false
})
}
}
}
})
}
Button(
onClick = vm::submitExpense,
modifier = Modifier.padding(16.dp),
shape = Shapes.large,
enabled = state.category != null
) {
Text("Submit expense")
}
}
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantScreen(navController: NavController, viewModel: AssistantViewModel
= viewModel()) {
val conversations = remember { mutableStateListOf<String>() }
var userInput by remember { mutableStateOf("") }
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text("AI Assistant") },
colors = TopAppBarDefaults.mediumTopAppBarColors(containerColor =
TopAppBarBackground),
navigationIcon = {
Surface(
onClick = navController::popBackStack,
color = Color.Transparent,
) {
Row(modifier = Modifier.padding(vertical = 10.dp)) {
Icon(Icons.Rounded.KeyboardArrowLeft,
contentDescription = "Back")
Text("Settings")
}
}
}
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
LazyColumn(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
items(conversations) { message ->
Text(
text = message,
modifier = Modifier
.padding(8.dp)
.background(BackgroundElevated, Shapes.medium)
.padding(16.dp)
)
}
}
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = BackgroundElevated,
modifier = Modifier
.height(44.dp)
.weight(1f)
.padding(start = 16.dp),
shape = Shapes.large,
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxHeight()
) {
UnstyledTextField(
value = userInput,
onValueChange = { userInput = it },
placeholder = { Text("Ask something...") },
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
)
}
}
IconButton(onClick = {
if (userInput.isNotBlank()) {
conversations.add("You: $userInput")
viewModel.sendMessage(userInput) { reply ->
conversations.add("Assistant: $reply")
}
userInput = ""
}
}) {
Icon(Icons.Rounded.Send, contentDescription = "Send")
}
}
}
}
)
}
@Preview(showBackground = true)
@Composable
fun AssistantScreenPreview() {
AssistantScreen(navController = rememberNavController())
}
4.7.3 Categories Screen (pages/Categories.kt)
package com.matechmatrix.goodbyemoney.pages
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowLeft
import androidx.compose.material.icons.rounded.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.github.skydoves.colorpicker.compose.*
import com.matechmatrix.goodbyemoney.R
import com.matechmatrix.goodbyemoney.components.TableRow
import com.matechmatrix.goodbyemoney.components.UnstyledTextField
import com.matechmatrix.goodbyemoney.ui.theme.*
import com.matechmatrix.goodbyemoney.viewmodels.CategoriesViewModel
import me.saket.swipe.SwipeAction
import me.saket.swipe.SwipeableActionsBox
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalAnimationApi::class
)
@Composable
fun Categories(
navController: NavController, vm: CategoriesViewModel = viewModel()
) {
val uiState by vm.uiState.collectAsState()
Scaffold(topBar = {
MediumTopAppBar(title = { Text("Categories") },
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = TopAppBarBackground
),
navigationIcon = {
Surface(
onClick = navController::popBackStack,
color = Color.Transparent,
) {
Row(modifier = Modifier.padding(vertical = 10.dp)) {
Icon(
Icons.Rounded.KeyboardArrowLeft, contentDescription = "Settings"
)
Text("Settings")
}
}
})
}, content = { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
AnimatedVisibility(visible = true) {
LazyColumn(
modifier = Modifier
.padding(16.dp)
.clip(Shapes.large)
.fillMaxWidth()
) {
itemsIndexed(
uiState.categories,
key = { _, category -> category.name }) { index, category ->
SwipeableActionsBox(
endActions = listOf(
SwipeAction(
icon = painterResource(R.drawable.delete),
background = Destructive,
onSwipe = { vm.deleteCategory(category) }
),
),
modifier = Modifier.animateItemPlacement()
) {
TableRow(modifier = Modifier.background(BackgroundElevated)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp)
) {
Surface(
color = category.color,
shape = CircleShape,
border = BorderStroke(
width = 2.dp,
color = Color.White
),
modifier = Modifier.size(16.dp)
) {}
Text(
category.name,
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 10.dp
),
style = Typography.bodyMedium,
)
}
}
}
if (index < uiState.categories.size - 1) {
Row(modifier =
Modifier.background(BackgroundElevated).height(1.dp)) {
Divider(
modifier = Modifier.padding(start = 16.dp),
thickness = 1.dp,
color = DividerColor
)
}
}
}
}
}
}
Row(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (uiState.colorPickerShowing) {
Dialog(onDismissRequest = vm::hideColorPicker) {
Surface(color = BackgroundElevated, shape = Shapes.large) {
Column(
modifier = Modifier.padding(all = 30.dp)
) {
Text("Select a color", style = Typography.titleLarge)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
AlphaTile(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(6.dp)),
controller = colorPickerController
)
}
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.padding(10.dp),
controller = colorPickerController,
onColorChanged = { envelope ->
vm.setNewCategoryColor(envelope.color)
},
)
TextButton(
onClick = vm::hideColorPicker,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
Text("Done")
}
}
}
}
}
Surface(
onClick = vm::showColorPicker,
shape = CircleShape,
color = uiState.newCategoryColor,
border = BorderStroke(
width = 2.dp,
color = Color.White
),
modifier = Modifier.size(width = 24.dp, height = 24.dp)
) {}
Surface(
color = BackgroundElevated,
modifier = Modifier
.height(44.dp)
.weight(1f)
.padding(start = 16.dp),
shape = Shapes.large,
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxHeight()
) {
UnstyledTextField(
value = uiState.newCategoryName,
onValueChange = vm::setNewCategoryName,
placeholder = { Text("Category name") },
modifier = Modifier
.fillMaxWidth(),
maxLines = 1,
)
}
}
IconButton(
onClick = vm::createNewCategory,
modifier = Modifier
.padding(start = 16.dp)
) {
Icon(
Icons.Rounded.Send,
"Create category"
)
}
}
}
})
}
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun CategoriesPreview() {
GoodbyeMoneyTheme {
Categories(navController = rememberNavController())
}
}
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.matechmatrix.goodbyemoney.components.PickerTrigger
import com.matechmatrix.goodbyemoney.components.expensesList.ExpensesList
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.ui.theme.GoodbyeMoneyTheme
import com.matechmatrix.goodbyemoney.ui.theme.LabelSecondary
import com.matechmatrix.goodbyemoney.ui.theme.TopAppBarBackground
import com.matechmatrix.goodbyemoney.ui.theme.Typography
import com.matechmatrix.goodbyemoney.viewmodels.ExpensesViewModel
import java.text.DecimalFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Expenses(
navController: NavController,
vm: ExpensesViewModel = viewModel()
) {
val recurrences = listOf(
Recurrence.Daily,
Recurrence.Weekly,
Recurrence.Monthly,
Recurrence.Yearly
)
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text("Expenses") },
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = TopAppBarBackground
)
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Total for:",
style = Typography.bodyMedium,
)
PickerTrigger(
state.recurrence.target ?: Recurrence.None.target,
onClick = { recurrenceMenuOpened = !recurrenceMenuOpened },
modifier = Modifier.padding(start = 16.dp)
)
DropdownMenu(expanded = recurrenceMenuOpened,
onDismissRequest = { recurrenceMenuOpened = false }) {
recurrences.forEach { recurrence ->
DropdownMenuItem(text = { Text(recurrence.target) }, onClick = {
vm.setRecurrence(recurrence)
recurrenceMenuOpened = false
})
}
}
}
Row(modifier = Modifier.padding(vertical = 32.dp)) {
Text(
"Rs",
style = Typography.bodyMedium,
color = LabelSecondary,
modifier = Modifier.padding(end = 4.dp, top = 4.dp)
)
Text(
DecimalFormat("0.#").format(state.sumTotal),
style = Typography.titleLarge
)
}
ExpensesList(
expenses = state.expenses,
modifier = Modifier
.weight(1f)
.verticalScroll(
rememberScrollState()
)
)
}
}
)
}
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun ExpensesPreview() {
GoodbyeMoneyTheme {
Expenses(navController = rememberNavController())
}
}
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.matechmatrix.goodbyemoney.R
import com.matechmatrix.goodbyemoney.components.ReportPage
import com.matechmatrix.goodbyemoney.models.Recurrence
import com.matechmatrix.goodbyemoney.ui.theme.TopAppBarBackground
import com.matechmatrix.goodbyemoney.viewmodels.ReportsViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
@Composable
fun Reports(vm: ReportsViewModel = viewModel()) {
val uiState = vm.uiState.collectAsState().value
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text("Reports") },
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = TopAppBarBackground
),
actions = {
IconButton(onClick = vm::openRecurrenceMenu) {
Icon(
painterResource(id = R.drawable.ic_today),
contentDescription = "Change recurrence"
)
}
DropdownMenu(
expanded = uiState.recurrenceMenuOpened,
onDismissRequest = vm::closeRecurrenceMenu
) {
recurrences.forEach { recurrence ->
DropdownMenuItem(text = { Text(recurrence.name) }, onClick = {
vm.setRecurrence(recurrence)
vm.closeRecurrenceMenu()
})
}
}
}
)
},
content = { innerPadding ->
val numOfPages = when (uiState.recurrence) {
Recurrence.Weekly -> 53
Recurrence.Monthly -> 12
Recurrence.Yearly -> 1
else -> 53
}
HorizontalPager(count = numOfPages, reverseLayout = true) { page ->
ReportPage(innerPadding, page, uiState.recurrence)
}
}
)
}
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import com.matechmatrix.goodbyemoney.components.TableRow
import com.matechmatrix.goodbyemoney.db
import com.matechmatrix.goodbyemoney.models.Category
import com.matechmatrix.goodbyemoney.models.Expense
import com.matechmatrix.goodbyemoney.ui.theme.BackgroundElevated
import com.matechmatrix.goodbyemoney.ui.theme.DividerColor
import com.matechmatrix.goodbyemoney.ui.theme.Shapes
import com.matechmatrix.goodbyemoney.ui.theme.TopAppBarBackground
import io.realm.kotlin.ext.query
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Settings(navController: NavController) {
val coroutineScope = rememberCoroutineScope()
var deleteConfirmationShowing by remember {
mutableStateOf(false)
}
delete(expenses)
delete(categories)
deleteConfirmationShowing = false
}
}
}
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text("Settings") },
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = TopAppBarBackground
)
)
},
content = { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Column(
modifier = Modifier
.padding(16.dp)
.clip(Shapes.large)
.background(BackgroundElevated)
.fillMaxWidth()
) {
TableRow(
label = "Categories",
hasArrow = true,
modifier = Modifier.clickable {
navController.navigate("settings/categories")
})
Divider(
modifier = Modifier
.padding(start = 16.dp), thickness = 1.dp, color = DividerColor
)
TableRow(
label = "AI Assistant",
hasArrow = true,
modifier = Modifier.clickable {
navController.navigate("settings/assistant")
})
Divider(
modifier = Modifier
.padding(start = 16.dp), thickness = 1.dp, color = DividerColor
)
TableRow(
label = "Erase all data",
isDestructive = true,
modifier = Modifier.clickable {
deleteConfirmationShowing = true
})
if (deleteConfirmationShowing) {
AlertDialog(
onDismissRequest = { deleteConfirmationShowing = false },
title = { Text("Are you sure?") },
text = { Text("This action cannot be undone.") },
confirmButton = {
TextButton(onClick = eraseAllData) {
Text("Delete everything")
}
},
dismissButton = {
TextButton(onClick = { deleteConfirmationShowing = false }) {
Text("Cancel")
}
}
)
}
}
}
}
)
}
import android.content.res.Configuration
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.matechmatrix.goodbyemoney.pages.*
import com.matechmatrix.goodbyemoney.ui.theme.GoodbyeMoneyTheme
import com.matechmatrix.goodbyemoney.ui.theme.TopAppBarBackground
import io.sentry.compose.withSentryObservableEffect
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
setContent {
GoodbyeMoneyTheme {
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navController = rememberNavController().withSentryObservableEffect()
val backStackEntry by navController.currentBackStackEntryAsState()
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar(containerColor = TopAppBarBackground) {
NavigationBarItem(
selected = backStackEntry?.destination?.route == "expenses",
onClick = { navController.navigate("expenses") },
label = {
Text("Expenses")
},
icon = {
Icon(
painterResource(id = R.drawable.upload),
contentDescription = "Upload"
)
}
)
NavigationBarItem(
selected = backStackEntry?.destination?.route == "reports",
onClick = { navController.navigate("reports") },
label = {
Text("Reports")
},
icon = {
Icon(
painterResource(id = R.drawable.bar_chart),
contentDescription = "Reports"
)
}
)
NavigationBarItem(
selected = backStackEntry?.destination?.route == "add",
onClick = { navController.navigate("add") },
label = {
Text("Add")
},
icon = {
Icon(
painterResource(id = R.drawable.add),
contentDescription = "Add"
)
}
)
NavigationBarItem(
selected =
backStackEntry?.destination?.route?.startsWith("settings")
?: false,
onClick = { navController.navigate("settings") },
label = {
Text("Settings")
},
icon = {
Icon(
painterResource(id = R.drawable.settings_outlined),
contentDescription = "Settings"
)
}
)
}
}
},
content = { innerPadding ->
NavHost(
navController = navController,
startDestination = "expenses"
) {
composable("expenses") {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Expenses(navController)
}
}
composable("reports") {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Reports()
}
}
composable("add") {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Add(navController)
}
}
composable("settings") {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Settings(navController)
}
}
composable("settings/categories") {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Categories(navController)
}
}
composable("settings/assistant") {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
AssistantScreen(navController)
}
}
}
}
)
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id "io.sentry.android.gradle" version "3.4.2"
id 'io.realm.kotlin'
}
android {
namespace 'com.matechmatrix.goodbyemoney'
compileSdk 34
defaultConfig {
applicationId "com.matechmatrix.goodbyemoney"
minSdk 28
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles
getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'io.realm.kotlin:library-base:1.6.0'
implementation "androidx.navigation:navigation-compose:2.5.3"
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation
"androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material3:material3:1.0.1'
implementation
'com.marosseleng.android:compose-material3-datetime-pickers:0.6.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
implementation "com.github.skydoves:colorpicker-compose:1.0.0"
implementation "me.saket.swipe:swipe:1.0.0"
implementation "io.github.serpro69:kotlin-faker:1.13.0"
implementation "com.github.tehras:charts:0.2.4-alpha"
implementation "com.google.accompanist:accompanist-pager:0.29.1-alpha"
implementation 'io.sentry:sentry-android:6.13.1'
implementation 'io.sentry:sentry-compose-android:6.13.1'
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation
"androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation
"androidx.compose.ui:ui-test-manifest:$compose_ui_version"
}
To reduce the app size and improve performance, the following optimizations were
performed:
ProGuard & R8: Used to remove unused code and obfuscate classes for security.
Resource Shrinking: Unused resources were removed to minimize APK size.
Jetpack Compose Optimization: Used LazyColumn for lists and avoided unnecessary
recompositions.
Google Play requires an Android App Bundle (AAB) or APK to be signed with a
secure key. The steps followed were:
Before publishing, Google Play reviewed the app for compliance with policies,
including:
User feedback and performance monitoring helped identify areas for improvement.
Updates included:
5.5 Summary
✅ Optimized and signed the app for Play Store.
✅ Successfully deployed to Google Play.
✅ Implemented post-release monitoring with Firebase and Sentry.
✅ Planned future updates for AI insights, multi-platform support, and cloud backup.
Chapter 6: Conclusion and Final
Thoughts
This chapter provides a comprehensive summary of the GoodBye Money project,
discussing the key takeaways, challenges faced, lessons learned, and future
possibilities. The journey from conceptualization to development, deployment, and
maintenance has been an enriching experience, contributing significantly to my
Android development expertise, problem-solving skills, and software engineering
knowledge.
The knowledge gained from this project extends beyond GoodBye Money and will be
instrumental in my future Android development endeavors.
Problem:
Solution:
✅ Implemented LazyColumn to optimize rendering for large lists.
✅ Used remember {} and derivedStateOf {} to minimize unnecessary
recompositions.
✅ Followed best practices for state hoisting to improve UI responsiveness.
Problem:
Solution:
✅ Optimized queries using Realm LiveData with Flow for reactive updates.
✅ Indexed frequently queried fields for faster lookups.
✅ Implemented background threading (using Kotlin Coroutines) to prevent UI lag.
Problem:
The app initially faced privacy concerns due to permissions required for AI insights and
storage access.
Google Play rejected the first submission due to missing Data Safety disclosures.
Solution:
✅ Created a detailed Privacy Policy hosted on a website.
✅ Reduced unnecessary permissions and requested them only when needed.
✅ Clearly outlined data collection and security practices in the Play Console..
MVVM Architecture:
Time Management:
Problem-Solving Skills:
User-Centric Approach:
Flutter version for iOS and Web Dashboard for a seamless multi-device
experience.
The combination of technology, user needs, and AI integration makes this app a
powerful and innovative financial tool. I look forward to:
Acknowledgments
I would like to express my sincere gratitude to: