PEMBUATAN APLIKASI PENCATATAN KEUANGAN PRIBADI - MYMONEYNOTES - Evaluasi Tengah Semester

 

Sifat : Kelompok
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

  1. 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

  2. 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:

  1. Pemisahan Tanggung Jawab:

    • UI (Compose) terpisah dari logika bisnis

    • Kode lebih mudah diuji dan dipelihara

  2. Reactive UI:

    • Dengan Jetpack Compose dan Flow/LiveData, UI akan otomatis diperbarui ketika data berubah

    • Saldo dan statistik selalu menampilkan data terkini

  3. Pengujian Lebih Mudah:

    • ViewModel dan Repository dapat diuji secara terpisah

    • UI testing lebih terfokus pada komponen visual

  4. Lifecycle Awareness:

    • ViewModel bertahan meskipun terjadi rotasi layar

    • Data tidak hilang saat konfigurasi berubah


Fitur Aplikasi

  1. 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.


  1. 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.


  1. 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.


  1. 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.


  1. 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.


  1. 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.


  1. 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:

  1. 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

  2. Komponen yang Lebih Rounded:

    • Elemen UI dengan sudut lebih bulat

    • Mengurangi ketajaman untuk tampilan yang lebih ramah dan nyaman

  3. 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

Postingan populer dari blog ini

Memahami Jetpack Compose dengan konsep Composable & Recomposition

Membuat Login Form Elegan dengan Jetpack Compose di Android Studio

Aplikasi Starbucks Clone dengan Android Studio