PEMBUATAN APLIKASI PENCATATAN KEUANGAN PRIBADI - MYMONEYNOTES - Evaluasi Tengah Semester
Anggota 1 : Iftala Zahri Sukmana (5025221002)
Anggota 2 : Muhammad Zharif Asyam Marzuqi (5025221163)
Kelas : G
Link GitHub : https://github.com/xcurvnubaim/ppb-ets-money-notes
Deskripsi
MyMoney Notes adalah aplikasi pengelola keuangan pribadi yang dirancang dengan antarmuka modern menggunakan Jetpack Compose. Aplikasi ini memungkinkan pengguna untuk mencatat, melacak, dan menganalisis pengeluaran serta pemasukan harian mereka secara mudah dan efisien.
Aplikasi ini menawarkan pengalaman pengguna yang intuitif dengan desain Material You yang dapat menyesuaikan warna dinamis berdasarkan wallpaper perangkat pengguna pada Android 12 ke atas. Fitur utama aplikasi ini mencakup pencatatan transaksi keuangan dengan kategori dan deskripsi, tampilan ringkasan saldo yang jelas, daftar riwayat transaksi secara kronologis, serta visualisasi data berupa grafik lingkaran yang memudahkan pengguna memahami pola pengeluaran dan pemasukan mereka.
Pengguna dapat menambahkan transaksi baru dengan mudah, memilih jenis transaksi (pemasukan atau pengeluaran), mengkategorikan transaksi dari beberapa pilihan seperti Makanan, Transport, Hiburan, Belanja, Tagihan, Gaji, Hadiah, dan lainnya, serta menyertakan deskripsi untuk setiap transaksi. Pada halaman utama, pengguna dapat melihat saldo terkini mereka beserta total pemasukan dan pengeluaran. Halaman statistik menyajikan analisis visual yang membantu pengguna memahami distribusi keuangan mereka berdasarkan kategori, dilengkapi dengan persentase untuk setiap kategori.
Dengan antarmuka yang bersih dan penggunaan yang mudah, MyMoney Notes adalah solusi praktis bagi siapa saja yang ingin mengelola keuangan pribadi mereka dengan lebih efektif. Aplikasi ini juga menyediakan pengaturan untuk menyesuaikan preferensi tampilan, termasuk opsi untuk mengaktifkan atau menonaktifkan fitur warna dinamis Material You, sehingga pengguna dapat menyesuaikan aplikasi sesuai dengan preferensi visual mereka.
Arsitektur
MVVM (Model-View-ViewModel) adalah pola arsitektur perangkat lunak yang dirancang untuk memisahkan logika bisnis dan presentasi dari antarmuka pengguna (UI). Pola arsitektur ini dikembangkan oleh Microsoft sebagai varian dari pola MVC (Model-View-Controller) dan telah menjadi sangat populer dalam pengembangan aplikasi Android modern, terutama dengan dukungan resmi dari Google melalui Android Architecture Components. Komponen utama dalam arsitektur ini ialah sebagai berikut
1. Model
Model merepresentasikan data dan logika bisnis aplikasi, termasuk:
Entity (Entitas):
Transaction - menyimpan data transaksi (id, jumlah, tipe, kategori, deskripsi, tanggal)
Category - menyimpan data kategori (id, nama, ikon, warna)
Repository:
TransactionRepository - mengelola akses data ke database lokal
CategoryRepository - mengelola akses data kategori
Data Source:
Room Database untuk penyimpanan lokal
DAO (Data Access Object) untuk operasi database
2. View
View dalam konteks Jetpack Compose adalah UI yang menampilkan data kepada pengguna:
Layar Utama: Menampilkan saldo, ringkasan pemasukan/pengeluaran
Layar Transaksi: Daftar riwayat transaksi kronologis
Layar Statistik: Visualisasi data berupa grafik lingkaran
Layar Tambah/Edit Transaksi: Form untuk menambah/mengedit transaksi
Layar Pengaturan: Pengaturan preferensi tampilan (Material You)
View hanya bertanggung jawab untuk menampilkan UI dan menangkap interaksi pengguna, tanpa logika bisnis.
3. ViewModel
ViewModel menjembatani Model dan View, serta bertanggung jawab untuk:
MainViewModel:
Menyediakan data saldo terkini dan ringkasan keuangan
Menghitung total pemasukan dan pengeluaran
TransactionViewModel:
Mengelola daftar transaksi
Operasi CRUD transaksi (Create, Read, Update, Delete)
StatisticsViewModel:
Mengolah data untuk visualisasi grafik
Menghitung persentase setiap kategori pengeluaran dan pemasukan
SettingsViewModel:
Mengelola preferensi pengguna (tema, Material You)
Maka, aliran data dalam aplikasi dapat dijelaskan sebagai berikut
Penambahan Transaksi Baru:
View: User mengisi form transaksi baru
ViewModel: TransactionViewModel memvalidasi input
Model: TransactionRepository menyimpan transaksi ke database
ViewModel: Data disimpan, memicu update UI
View: UI diperbarui menampilkan transaksi baru
Melihat Statistik:
View: User membuka layar statistik
ViewModel: StatisticsViewModel meminta data transaksi
Model: TransactionRepository mengambil data transaksi
ViewModel: Data diproses untuk grafik
View: Menampilkan grafik lingkaran dengan persentase kategori
Kelebihan pengaplikasian arsitektur MVVM ini ialah sebagai berikut:
Pemisahan Tanggung Jawab:
UI (Compose) terpisah dari logika bisnis
Kode lebih mudah diuji dan dipelihara
Reactive UI:
Dengan Jetpack Compose dan Flow/LiveData, UI akan otomatis diperbarui ketika data berubah
Saldo dan statistik selalu menampilkan data terkini
Pengujian Lebih Mudah:
ViewModel dan Repository dapat diuji secara terpisah
UI testing lebih terfokus pada komponen visual
Lifecycle Awareness:
ViewModel bertahan meskipun terjadi rotasi layar
Data tidak hilang saat konfigurasi berubah
Fitur Aplikasi
Pencatatan Transaksi Keuangan
Aplikasi MyMoney Notes menyediakan antarmuka yang intuitif untuk mencatat transaksi keuangan harian. Pengguna dapat dengan mudah memasukkan informasi transaksi seperti jenis transaksi (pemasukan atau pengeluaran), kategori, jumlah, dan deskripsi. Proses pencatatan dirancang sesederhana mungkin agar pengguna dapat dengan cepat mencatat transaksi tanpa kerumitan.
Kategorisasi Transaksi
Aplikasi dilengkapi dengan sistem kategorisasi transaksi yang komprehensif. Pengguna dapat mengklasifikasikan transaksi ke dalam berbagai kategori seperti Makanan, Transportasi, Hiburan, Belanja, Tagihan, Gaji, Hadiah, dan lainnya. Kategorisasi ini memudahkan pengguna untuk melacak pola pengeluaran dan menganalisis kebiasaan finansial mereka dengan lebih terstruktur.
Tampilan Ringkasan Keuangan
Halaman utama aplikasi menampilkan ringkasan finansial yang jelas dan informatif. Pengguna dapat melihat saldo saat ini, total pemasukan, dan total pengeluaran dalam satu tampilan. Informasi ini disajikan dengan desain yang menarik dan dilengkapi dengan indikator warna untuk memudahkan pemahaman terhadap kondisi keuangan mereka.
Daftar Riwayat Transaksi
Aplikasi menyediakan daftar lengkap semua transaksi yang telah dicatat. Transaksi ditampilkan secara kronologis dengan yang terbaru di bagian atas. Setiap entri transaksi menampilkan informasi penting seperti kategori, deskripsi, tanggal, waktu, dan jumlah. Desain kartu yang digunakan membuat informasi mudah dibaca dan dipahami dengan cepat.
Visualisasi Data dengan Grafik
Fitur statistik keuangan dilengkapi dengan visualisasi data berbentuk diagram lingkaran (pie chart) yang interaktif. Pengguna dapat melihat distribusi pengeluaran atau pemasukan berdasarkan kategori dengan representasi visual yang jelas. Grafik ini membantu pengguna untuk memahami pola keuangan mereka dengan cara yang lebih intuitif dibandingkan hanya melihat angka.
Analisis Rincian Kategori
Pada halaman statistik, pengguna dapat melihat rincian detail untuk setiap kategori. Setiap kategori ditampilkan dengan jumlah nominal dan persentase dari total. Visualisasi batang kemajuan (progress bar) berwarna untuk setiap kategori memudahkan pengguna membandingkan proporsi pengeluaran atau pemasukan antar kategori secara visual.
Dukungan Material You (Monet)
Aplikasi mendukung sistem warna dinamis Material You pada perangkat Android 12 ke atas. Fitur ini memungkinkan tema aplikasi secara otomatis menyesuaikan dengan warna wallpaper perangkat pengguna, menciptakan pengalaman yang lebih personal dan terintegrasi dengan tema sistem perangkat. Pengguna juga dapat menonaktifkan fitur ini melalui pengaturan jika mereka lebih menyukai tema warna statis.
Desain UI
Aplikasi MyMoney Notes menggunakan desain modern berbasis Material You dengan Jetpack Compose sebagai framework UI-nya. Berikut penjelasan detail tentang desain UI yang digunakan:
Prinsip Desain Material You
Material You adalah evolusi terbaru dari Material Design yang memperkenalkan beberapa fitur utama:
Warna Dinamis:
Mengekstrak warna dari wallpaper perangkat pengguna (Android 12+)
Menciptakan skema warna personal yang konsisten di seluruh aplikasi
Palet warna yang harmonis dengan aksesibilitas yang baik
Komponen yang Lebih Rounded:
Elemen UI dengan sudut lebih bulat
Mengurangi ketajaman untuk tampilan yang lebih ramah dan nyaman
Adaptif pada Berbagai Perangkat:
Layout yang responsif untuk berbagai ukuran layar
Optimasi untuk mode gelap/terang
Layout Utama Aplikasi
1. Layar Beranda (Home Screen)
Layar beranda menampilkan ikhtisar keuangan pengguna dengan komponen-komponen berikut:
Card Saldo:
Card besar di bagian atas yang menampilkan saldo terkini
Menampilkan total pemasukan dan pengeluaran dengan kode warna (hijau untuk pemasukan, merah untuk pengeluaran)
Animasi transisi yang halus saat nilai berubah
Riwayat Transaksi Terbaru:
LazyColumn untuk daftar transaksi terbaru
Setiap item menampilkan ikon kategori, jumlah, tanggal, dan deskripsi singkat
Swipe action untuk edit/hapus transaksi
Pull-to-refresh untuk memperbarui data
Floating Action Button (FAB):
Posisi di sudut kanan bawah
Ikon "+" untuk menambah transaksi baru
Warna aksen yang menyesuaikan dengan skema warna dinamis
2. Layar Statistik (Statistics Screen)
Layar yang menyajikan visualisasi data keuangan:
Grafik Lingkaran (Pie Chart):
Menampilkan distribusi pengeluaran berdasarkan kategori
Setiap kategori memiliki warna berbeda dan label persentase
Animasi saat grafik pertama kali dimuat atau berubah
Daftar Detail Kategori:
Di bawah grafik, menampilkan daftar kategori dengan:
Ikon kategori
Nama kategori
Jumlah total
Persentase dari keseluruhan
Tab/Toggle Switch:
Untuk beralih antara tampilan pengeluaran dan pemasukan
Menggunakan TabRow dengan indikator yang beranimasi
3. Layar Tambah Transaksi
Toggle Button:
Untuk memilih jenis transaksi (Pemasukan/Pengeluaran)
Menggunakan warna yang kontras (hijau/merah)
Input Field:
TextField untuk jumlah (dengan keyboard numerik)
TextField untuk deskripsi
DatePicker untuk memilih tanggal
Pemilih Kategori:
Grid atau Flow layout dengan ikon kategori
Efek highlight saat kategori dipilih
Animasi ripple saat diklik
Tombol Simpan:
Button lebar di bagian bawah
Warna primer yang sesuai dengan skema warna dinamis
Komponen UI Utama
Navigas
Bottom Navigation Bar:
Ikon dan label untuk setiap bagian utama (Beranda, Statistik, Pengaturan)
Indikator posisi aktif dengan animasi
Atau Navigation Rail pada tablet/layar lebar
Card dan Surface
Elevated Cards:
Untuk menonjolkan informasi penting seperti saldo
Shadow dan elevasi yang subtil
Outlined Cards:
Untuk item transaksi
Border tipis dengan sudut yang dibulatkan
Tipografi
Hierarki tipografi yang jelas:
DisplayLarge: Judul utama (misalnya "MyMoney Notes")
HeadlineMedium: Subjudul (misalnya "Statistik Bulan Ini")
BodyLarge: Teks jumlah transaksi
BodyMedium: Deskripsi transaksi
LabelSmall: Label kategori, tanggal
Animasi dan Transisi
Animasi Halaman:
Transisi fade/slide saat berpindah antar halaman
Animasi Komponen:
Ripple effect pada item yang dapat diklik
Animasi expand/collapse untuk detail transaksi
Transisi halus saat nilai numerik berubah
Ikon dan Visual
Ikon Kategori:
Set ikon yang konsisten untuk setiap kategori transaksi
Warna ikon yang sesuai dengan kategori (atau mengikuti skema warna dinamis)
Ilustrasi:
Ilustrasi minimalis untuk status kosong (empty state)
Menampilkan ilustrasi saat belum ada transaksi
Implementasi
com/example/mymoneynotes/data/Transaction.kt
package com.example.mymoneynotes.data
import java.util.Date
enum class TransactionType {
INCOME, EXPENSE
}
enum class TransactionCategory {
FOOD, TRANSPORT, ENTERTAINMENT, SHOPPING, BILLS, SALARY, GIFT, OTHER
}
data class Transaction(
val id: String = java.util.UUID.randomUUID().toString(),
val type: TransactionType,
val category: TransactionCategory,
val amount: Double,
val description: String,
val date: Date = Date()
)
Kode tersebut mendefinisikan sebuah struktur data untuk mencatat transaksi keuangan dalam aplikasi. Pertama, terdapat dua enum class: TransactionType yang membedakan antara INCOME (pemasukan) dan EXPENSE (pengeluaran), serta TransactionCategory yang mengklasifikasikan transaksi ke dalam beberapa kategori seperti FOOD, TRANSPORT, ENTERTAINMENT, dan lainnya. Kemudian, terdapat sebuah data class bernama Transaction yang berfungsi sebagai wadah untuk menyimpan informasi detail setiap transaksi. Setiap objek Transaction akan memiliki ID unik yang dibuat secara otomatis, tipe transaksi (pemasukan atau pengeluaran), kategori transaksi, jumlah uang (amount), deskripsi transaksi, dan tanggal transaksi yang secara bawaan akan diisi dengan tanggal saat objek dibuat.
com/example/mymoneynotes/data/dao/TransactionDao.kt
@Dao
interface TransactionDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTransaction(transaction: TransactionEntity)
@Delete
suspend fun deleteTransaction(transaction: TransactionEntity)
@Query("SELECT * FROM transactions ORDER BY timestamp DESC")
fun getAllTransactions(): Flow<List<TransactionEntity>>
@Query("SELECT * FROM transactions WHERE type = :type ORDER BY timestamp DESC")
fun getTransactionsByType(type: String): Flow<List<TransactionEntity>>
@Query("SELECT * FROM transactions WHERE category = :category ORDER BY timestamp DESC")
fun getTransactionsByCategory(category: String): Flow<List<TransactionEntity>>
@Query("SELECT SUM(amount) FROM transactions WHERE type = 'INCOME'")
fun getTotalIncome(): Flow<Double?>
@Query("SELECT SUM(amount) FROM transactions WHERE type = 'EXPENSE'")
fun getTotalExpense(): Flow<Double?>
}
Antarmuka TransactionDao ini, yang dianotasi dengan @Dao, menyediakan cara untuk berinteraksi dengan data transaksi yang disimpan dalam database aplikasi. Ia mendefinisikan berbagai fungsi untuk melakukan operasi seperti menambahkan transaksi baru (insertTransaction), menghapus transaksi (deleteTransaction), mengambil semua transaksi atau transaksi berdasarkan tipe atau kategori tertentu (getAllTransactions, getTransactionsByType, getTransactionsByCategory), serta menghitung total pemasukan (getTotalIncome) dan total pengeluaran (getTotalExpense). Penggunaan Flow dari Kotlin Coroutines memungkinkan pengamatan perubahan data secara asynchronous, dan fungsi-fungsi yang ditandai dengan suspend menunjukkan bahwa operasi database ini dijalankan tanpa memblokir thread utama aplikasi.
com/example/mymoneynotes/data/database/AppDatabase.kt
@Database(entities = [TransactionEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun transactionDao(): TransactionDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"mymoney_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
Kelas AppDatabase ini, yang dianotasi dengan @Database, mendefinisikan skema database aplikasi yang menggunakan Room Persistence Library. Ia mendeklarasikan TransactionEntity sebagai entitas (tabel) dalam database, menetapkan versi database saat ini adalah 1, dan menonaktifkan ekspor skema. Kelas abstrak ini menyediakan akses ke TransactionDao melalui fungsi abstrak transactionDao(). Bagian companion object mengimplementasikan pola Singleton untuk memastikan hanya ada satu instance dari AppDatabase yang dibuat selama masa pakai aplikasi. Fungsi getDatabase(context: Context) digunakan untuk mendapatkan instance database, membuat instance baru jika belum ada, dan menggunakan mekanisme fallback to destructive migration untuk menangani perubahan skema database.
com/example/mymoneynotes/data/entities/TransactionEntity.kt
@Entity(tableName = "transactions")
data class TransactionEntity(
@PrimaryKey val id: String,
val type: String, // Storing as String since Room doesn't directly support Enums
val category: String,
val amount: Double,
val description: String,
val timestamp: Long // Store date as timestamp for Room
) {
// Convert from domain model to entity
companion object {
fun fromTransaction(transaction: com.example.mymoneynotes.data.Transaction): TransactionEntity {
return TransactionEntity(
id = transaction.id,
type = transaction.type.name,
category = transaction.category.name,
amount = transaction.amount,
description = transaction.description,
timestamp = transaction.date.time
)
}
}
// Convert to domain model
fun toTransaction(): com.example.mymoneynotes.data.Transaction {
return com.example.mymoneynotes.data.Transaction(
id = id,
type = TransactionType.valueOf(type),
category = TransactionCategory.valueOf(category),
amount = amount,
description = description,
date = Date(timestamp)
)
}
}
Kelas TransactionEntity ini, yang dianotasi dengan @Entity(tableName = "transactions"), merepresentasikan struktur tabel "transactions" dalam database Room. Setiap kolom dalam tabel dipetakan ke properti dalam kelas ini, dengan id sebagai kunci utama (@PrimaryKey). Tipe transaksi (type) dan kategori (category) disimpan sebagai String karena Room tidak mendukung enum secara langsung, sedangkan tanggal disimpan sebagai timestamp (Long) untuk kompatibilitas dengan Room. Companion object menyediakan fungsi fromTransaction untuk mengonversi objek domain Transaction menjadi TransactionEntity untuk disimpan dalam database. Selain itu, terdapat fungsi toTransaction untuk mengonversi kembali TransactionEntity dari database menjadi objek domain Transaction.
com/example/mymoneynotes/data/repository/TransactionRepository.kt
class TransactionRepository(private val transactionDao: TransactionDao) {
val allTransactions: Flow<List<Transaction>> = transactionDao.getAllTransactions()
.map { entities -> entities.map { it.toTransaction() } }
val incomeTransactions: Flow<List<Transaction>> = transactionDao.getTransactionsByType(TransactionType.INCOME.name)
.map { entities -> entities.map { it.toTransaction() } }
val expenseTransactions: Flow<List<Transaction>> = transactionDao.getTransactionsByType(TransactionType.EXPENSE.name)
.map { entities -> entities.map { it.toTransaction() } }
val transactionsByType: Flow<Map<String, List<Transaction>>> = transactionDao.getTransactionsByType(TransactionType::class.java.name)
.map { entities: List<TransactionEntity> ->
entities.groupBy { it.type }
.mapValues { entry: Map.Entry<String, List<TransactionEntity>> -> entry.value.map { it.toTransaction() } }
}
val transactionsByCategory: Flow<Map<String, List<Transaction>>> = transactionDao.getTransactionsByCategory(TransactionCategory::class.java.name)
.map { entities ->
entities.groupBy { it.category }
.mapValues { entry -> entry.value.map { it.toTransaction() } }
}
val totalIncome: Flow<Double> = transactionDao.getTotalIncome()
.map { it ?: 0.0 }
val totalExpense: Flow<Double> = transactionDao.getTotalExpense()
.map { it ?: 0.0 }
suspend fun insertTransaction(transaction: Transaction) {
transactionDao.insertTransaction(TransactionEntity.fromTransaction(transaction))
}
suspend fun deleteTransaction(transaction: Transaction) {
transactionDao.deleteTransaction(TransactionEntity.fromTransaction(transaction))
}
}
Kelas TransactionRepository ini berfungsi sebagai lapisan abstraksi antara sumber data (dalam hal ini, TransactionDao yang berinteraksi dengan database) dan lapisan UI atau view model. Ia menyediakan akses ke berbagai aliran data transaksi dalam bentuk objek domain Transaction, bukan lagi TransactionEntity. Properti seperti allTransactions, incomeTransactions, dan expenseTransactions mengambil data dari TransactionDao dan kemudian menggunakan fungsi map untuk mengonversi setiap TransactionEntity menjadi objek Transaction. Demikian pula, transactionsByType dan transactionsByCategory mengelompokkan transaksi berdasarkan tipe dan kategori masing-masing. Properti totalIncome dan totalExpense mengambil total nilai dari transaksi pemasukan dan pengeluaran, menangani kemungkinan nilai null dengan mengembalikannya sebagai 0.0 jika tidak ada data. Terakhir, fungsi insertTransaction dan deleteTransaction menerima objek Transaction domain dan mengonversinya menjadi TransactionEntity sebelum meneruskannya ke TransactionDao untuk operasi database.
com/example/mymoneynotes/ui/viewmodels/TransactionViewModels.kt
class TransactionViewModel(private val repository: TransactionRepository) : ViewModel() {
// Stream of all transactions, sorted by date (newest first)
val allTransactions: StateFlow<List<Transaction>> = repository.allTransactions
.map { transactions -> transactions.sortedByDescending { it.date } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = emptyList()
)
// Financial summary combining income, expense and balance
val financialSummary: StateFlow<FinancialSummary> = combine(
repository.totalIncome,
repository.totalExpense
) { income, expense ->
FinancialSummary(
totalIncome = income,
totalExpense = expense,
balance = income - expense
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = FinancialSummary(0.0, 0.0, 0.0)
)
// Helper for stats screen to filter transactions by type
fun getTransactionsByType(type: TransactionType): Flow<List<Transaction>> =
repository.allTransactions
.map { transactions -> transactions.filter { it.type == type } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = emptyList()
)
// Returns transactions from the last 30 days
val recentTransactions: StateFlow<List<Transaction>> = repository.allTransactions
.map { transactions ->
val thirtyDaysAgo = Date(System.currentTimeMillis() - (30 * 24 * 60 * 60 * 1000))
transactions
.filter { it.date.after(thirtyDaysAgo) }
.sortedByDescending { it.date }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = emptyList()
)
// Insert a new transaction
fun addTransaction(transaction: Transaction) {
viewModelScope.launch {
repository.insertTransaction(transaction)
}
}
// Delete a transaction
fun deleteTransaction(transaction: Transaction) {
viewModelScope.launch {
repository.deleteTransaction(transaction)
}
}
/**
* Data class representing financial summary metrics
*/
data class FinancialSummary(
val totalIncome: Double,
val totalExpense: Double,
val balance: Double
) {
val incomePercentage: Float get() =
if (totalIncome + totalExpense > 0) (totalIncome / (totalIncome + totalExpense)).toFloat() else 0f
val expensePercentage: Float get() =
if (totalIncome + totalExpense > 0) (totalExpense / (totalIncome + totalExpense)).toFloat() else 0f
}
}
TransactionViewModel adalah ViewModel yang bertugas menyimpan dan mengelola data transaksi serta logika bisnis ringkasan keuangan, terpisah dari UI (Activity/Fragment). Ia memanfaatkan Kotlin Coroutines dan Flow untuk mengambil data dari TransactionRepository secara reaktif, menjaganya tetap up-to-date, dan bertahan terhadap perubahan konfigurasi (misalnya rotasi layar). Sementara itu, TransactionViewModelFactory adalah Factory untuk membuat instance TransactionViewModel dengan menyuntikkan dependensi TransactionRepository, sehingga memudahkan pengujian dan penerapan prinsip Dependency Injection.Di dalam TransactionViewModel terdapat beberapa properti yang menggunakan Flow dan stateIn: repository.allTransactions: Flow<List<Transaction>> dari repository. .map { … }: memproses—misalnya mengurutkan berdasarkan tanggal. .stateIn(scope, started, initial): mengonversi Flow menjadi StateFlow, menyimpan nilai terakhir dan otomatis mulai/mematikan koleksi sesuai lifecycle (viewModelScope, SharingStarted.WhileSubscribed),
com/example/mymoneynotes/ui/screens/HomeScreen.kt
fun HomeScreen(
transactions: List<Transaction>,
financialSummary: FinancialSummary,
onDeleteTransaction: (Transaction) -> Unit
) {
val listState = rememberLazyListState()
val currencyFormatter = NumberFormat.getCurrencyInstance(Locale("in", "ID"))
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Balance Card with Surface and Elevation
Surface(
modifier = Modifier
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 2.dp
) {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth()
) {
Text(
text = "Current Balance",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = currencyFormatter.format(financialSummary.balance).replace("IDR", "Rp"),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.ExtraBold,
color = if (financialSummary.balance >= 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(24.dp))
// Income and Expense Summary Cards
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Income Card
SummaryCard(
title = "Income",
amount = financialSummary.totalIncome,
icon = Icons.Filled.ArrowUpward,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
// Divider
Divider(
modifier = Modifier
.width(1.dp)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.outlineVariant
)
// Expense Card
SummaryCard(
title = "Expense",
amount = financialSummary.totalExpense,
icon = Icons.Filled.ArrowDownward,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Progress Bar for Income/Expense Ratio
if (financialSummary.totalIncome + financialSummary.totalExpense > 0) {
val incomeRatio = financialSummary.incomePercentage
LinearProgressIndicator(
progress = incomeRatio,
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
trackColor = MaterialTheme.colorScheme.errorContainer,
color = MaterialTheme.colorScheme.primaryContainer
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${(incomeRatio * 100).toInt()}% Income",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${(100 - incomeRatio * 100).toInt()}% Expense",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Recent Transactions Section
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Recent Transactions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
AssistChip(
onClick = { /* TODO: Add filter functionality */ },
label = { Text("Last 30 days") },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
labelColor = MaterialTheme.colorScheme.onSecondaryContainer
)
)
}
AnimatedVisibility(
visible = transactions.isEmpty(),
enter = fadeIn(spring()),
exit = fadeOut(spring())
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No transactions yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add your first transaction using the + button",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
AnimatedVisibility(
visible = transactions.isNotEmpty(),
enter = fadeIn(spring()),
exit = fadeOut(spring())
) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
) {
items(transactions) { transaction ->
TransactionItem(
transaction = transaction,
onDelete = { onDeleteTransaction(transaction) }
)
}
}
}
}
}
}
HomeScreen: Composable utama yang menampilkan kartu saldo, ringkasan pemasukan/pengeluaran, dan daftar transaksi terbaru; menerima data dari ViewModel via parameter (transactions, financialSummary) dan callback untuk menghapus transaksi.
SummaryCard: Composable kecil untuk menampilkan satu ringkasan (Income atau Expense) dengan ikon, judul, dan jumlah yang telah diformat.
TransactionItem: Composable untuk menampilkan satu transaksi dalam LazyColumn, lengkap dengan kategori, deskripsi, tanggal, waktu, jumlah, dan dialog konfirmasi hapus.
com/example/mymoneynotes/ui/viewmodels/StatsScreen.kt
fun StatsScreen(transactions: List<Transaction>) {
var selectedType by remember { mutableStateOf(TransactionType.EXPENSE) }
val scrollState = rememberScrollState()
// Animation for type selection
val animatedSelectedType = remember { mutableStateOf(selectedType) }
LaunchedEffect(selectedType) {
animatedSelectedType.value = selectedType
}
val filteredTransactions = transactions.filter { it.type == selectedType }
val totalAmount = filteredTransactions.sumOf { it.amount }
val categorySums = TransactionCategory.values().associateWith { category ->
filteredTransactions
.filter { it.category == category }
.sumOf { it.amount }
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Financial Statistics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 24.dp)
)
// Fixed toggle between Income and Expense (no white line)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
.shadow(elevation = 2.dp, shape = RoundedCornerShape(28.dp)),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp) // Fixed height for consistent appearance
) {
TransactionTypeSegment(
text = "Expenses",
selected = selectedType == TransactionType.EXPENSE,
onClick = { selectedType = TransactionType.EXPENSE },
modifier = Modifier.weight(1f)
)
TransactionTypeSegment(
text = "Income",
selected = selectedType == TransactionType.INCOME,
onClick = { selectedType = TransactionType.INCOME },
modifier = Modifier.weight(1f)
)
}
}
if (filteredTransactions.isEmpty()) {
EmptyState(selectedType = selectedType)
} else {
// Pie Chart Card
Card(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.padding(bottom = 16.dp)
.shadow(elevation = 3.dp, shape = RoundedCornerShape(24.dp)),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
PieChart(
data = categorySums.filter { it.value > 0 },
totalAmount = totalAmount
)
}
}
// Category Breakdown Card
Card(
modifier = Modifier
.fillMaxWidth()
.shadow(elevation = 3.dp, shape = RoundedCornerShape(24.dp)),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(24.dp)
) {
Text(
text = "${selectedType.name.lowercase().capitalize()} Breakdown",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Divider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
categorySums
.filter { it.value > 0 }
.toList()
.sortedByDescending { it.second }
.forEach { (category, amount) ->
val percentage = (amount / totalAmount * 100)
// Animate the progress
val animatedProgress by animateFloatAsState(
targetValue = (amount / totalAmount).toFloat(),
animationSpec = tween(durationMillis = 1000, easing = LinearEasing)
)
CategoryBreakdownItem(
category = category,
amount = amount,
percentage = percentage,
progress = animatedProgress
)
if (categorySums.filter { it.value > 0 }.toList().sortedByDescending { it.second }.last().first != category) {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
}
}
}
StatsScreen adalah Composable yang menampilkan statistik keuangan berdasarkan daftar transactions yang diberikan: pengguna dapat memilih antara “Expenses” atau “Income” (dengan animasi transisi), lalu semua transaksi difilter sesuai tipe terpilih dan diolah menjadi total serta ringkasan per kategori. UI terdiri dari toggle segmen, pie chart kustom (menggunakan Canvas), dan daftar breakdown kategori dengan progress bar animasi. Semua state lokal dikelola dengan remember, animasi di-handle oleh animate*AsState dan LaunchedEffect, dan scrolling diatur via rememberScrollState. Composable ini sepenuhnya stateless—ia hanya menerima data dan memanggil kembali (callback) ke ViewModel untuk perubahan data.
com/example/mymoneynotes/ui/viewmodels/AddTransactionScreen.kt
fun AddTransactionScreen(onTransactionAdded: (Transaction) -> Unit) {
var transactionType by remember { mutableStateOf(TransactionType.EXPENSE) }
var category by remember { mutableStateOf(TransactionCategory.FOOD) }
var amount by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var amountError by remember { mutableStateOf<String?>(null) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Text(
text = "New Transaction",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 32.dp)
)
// Transaction Type Selection Cards
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
TransactionTypeCard(
title = "Expense",
icon = Icons.Filled.ArrowDownward,
selected = transactionType == TransactionType.EXPENSE,
onClick = { transactionType = TransactionType.EXPENSE },
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TransactionTypeCard(
title = "Income",
icon = Icons.Filled.ArrowUpward,
selected = transactionType == TransactionType.INCOME,
onClick = { transactionType = TransactionType.INCOME },
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
}
// Category Selection
Text(
text = "Category",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Start)
)
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
OutlinedTextField(
value = category.name,
onValueChange = {},
readOnly = true,
leadingIcon = {
Box(
modifier = Modifier
.size(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(getCategoryColor(category))
)
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryEditable, true)
.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
),
shape = RoundedCornerShape(12.dp)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.defaultMinSize(minWidth = 200.dp)
) {
TransactionCategory.entries.forEach { selectedCategory ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.clip(RoundedCornerShape(6.dp))
.background(getCategoryColor(selectedCategory))
.padding(end = 8.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(selectedCategory.name)
}
},
onClick = {
category = selectedCategory
expanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Amount Input
Text(
text = "Amount (Rp)",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Start)
)
OutlinedTextField(
value = amount,
onValueChange = { input ->
val filteredInput = input.filter { it.isDigit() || it == '.' }
amount = filteredInput
amountError = if (filteredInput.isEmpty()) "Amount is required" else null
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
leadingIcon = { Icon(Icons.Outlined.MonetizationOn, contentDescription = null) },
placeholder = { Text("Enter amount") },
isError = amountError != null,
supportingText = {
AnimatedVisibility(
visible = amountError != null,
enter = fadeIn(tween(150)),
exit = fadeOut(tween(150))
) {
amountError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
errorBorderColor = MaterialTheme.colorScheme.error
),
shape = RoundedCornerShape(12.dp),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
// Description Input
Text(
text = "Description (Optional)",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.Start)
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
leadingIcon = { Icon(Icons.Outlined.Description, contentDescription = null) },
placeholder = { Text("Add notes") },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(32.dp))
// Save Button
Button(
onClick = {
if (amount.isNotEmpty()) {
val transaction = Transaction(
type = transactionType,
category = category,
amount = amount.toDouble(),
description = description.takeIf { it.isNotEmpty() } ?: category.name
)
onTransactionAdded(transaction)
} else {
amountError = "Amount is required"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = amount.isNotEmpty(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
elevation = ButtonDefaults.buttonElevation(
defaultElevation = 4.dp,
pressedElevation = 8.dp
)
) {
Text(
"SAVE TRANSACTION",
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp
)
}
}
}
AddTransactionScreen adalah Composable Jetpack Compose yang memungkinkan pengguna menambah transaksi baru dengan memilih tipe (Expense/Income), kategori, memasukkan jumlah, dan menambahkan deskripsi, serta memvalidasi input jumlah secara real-time. State di-manage menggunakan remember dan mutableStateOf untuk mempertahankan nilai input di setiap recomposition tanpa kehilangan data saat UI di-recompose UI memanfaatkan komponen Material3—seperti Card, OutlinedTextField, ExposedDropdownMenuBox, dan Button dengan elevation khusus—untuk menciptakan tampilan yang konsisten dan responsif
Komentar
Posting Komentar