diff --git a/.gitignore b/.gitignore index 3ec25d0..4a69f80 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ app.*.map.json *.env *.freezed.dart -google-services.json \ No newline at end of file +google-services.json +GoogleService-Info.plist +firebase_options.dart +**/firebase_app_id_file.json \ No newline at end of file diff --git a/README.md b/README.md index cf67e29..b445011 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,108 @@ -# vent_expense_pro +# VentExpensePro ๐Ÿ“ˆ -A new Flutter project. +**The Analog Digital Ledger** โ€” A lightweight, privacy-first personal finance application built with Flutter. -## Getting Started +VentExpensePro combines the simplicity of a paper ledger with the power of modern digital tools. It is designed for users who want total control over their financial data without compromising on aesthetics or ease of use. -This project is a starting point for a Flutter application. +--- -A few resources to get you started if this is your first Flutter project: +## โœจ Features -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +### ๐Ÿ“’ Smart Ledger +* **Effortless Logging**: Add transactions in seconds with a streamlined interface. +* **Categorization**: Organize expenses and income with customizable categories. +* **Rich Details**: Track dates, notes, and payment methods for every entry. -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +### ๐Ÿฆ Account Management +* **Multi-Account Support**: Manage Bank accounts, Cash, Credit Cards, and Wallets in one place. +* **Net Position**: Instantly view your total financial standing across all accounts. +* **Credit Settlement**: Specialized workflow for settling credit card bills. + +### ๐Ÿ“Š Reports & Insights +* **Visual Analytics**: Understand your spending patterns with dynamic charts (fl_chart). +* **PDF Export**: Generate professional expense reports for sharing or archival. +* **Data Filtering**: Drill down into your data by date range or account. + +### โ˜๏ธ Privacy-First Sync +* **Google Drive Sync**: Securely backup and sync your data using your own Google Drive. +* **App Data Scope**: Uses the `drive.appdata` hidden folder scope, ensuring your data is only accessible by the app. +* **Offline First**: Full functionality without an internet connection. + +### ๐ŸŽจ Premium Design +* **Flat Aesthetic**: A clean, modern "Flat Design" look that prioritizes readability. +* **Custom Typography**: Features *Lora* for elegance and *JetBrains Mono* for data precision. +* **Micro-Animations**: Smooth transitions and interactive elements for a premium feel. + +--- + +## ๐Ÿ› ๏ธ Tech Stack + +* **Framework**: [Flutter](https://flutter.dev/) (3.11+) +* **State Management**: [Provider](https://pub.dev/packages/provider) +* **Local Database**: [Sqflite](https://pub.dev/packages/sqflite) (SQLite) +* **Dependency Injection**: [GetIt](https://pub.dev/packages/get_it) +* **APIs & Infrastructure**: + * Google Drive API (Backup/Sync) + * Firebase Crashlytics (Crash Reporting) +* **Analytics & Reporting**: + * [fl_chart](https://pub.dev/packages/fl_chart) + * [pdf](https://pub.dev/packages/pdf) + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites +* Flutter SDK (^3.11.0) +* Android Studio / VS Code with Flutter Extension +* (Optional) Firebase account for Crashlytics + +### Setup +1. **Clone the repository**: + ```bash + git clone https://github.com/HellBus1/VentExpensePro.git + cd VentExpensePro + ``` + +2. **Install dependencies**: + ```bash + flutter pub get + ``` + +3. **Run the application**: + ```bash + flutter run + ``` + +### Production Build (Android) +The project is configured with ProGuard obfuscation and resource shrinking for optimized release builds. + +```bash +flutter build apk --release +``` + +*Note: For Crashlytics functionality, ensure `google-services.json` is placed in `android/app/`.* + +--- + +## ๐Ÿ—๏ธ Architecture + +The project follows a **Clean Architecture** pattern to ensure maintainability and testability: + +- **`lib/domain`**: Core business logic, entities, and repository interfaces (Pure Dart). +- **`lib/data`**: Implementation of repositories, SQLite data sources, and external service integrations. +- **`lib/presentation`**: UI layer consisting of Screens, Widgets (Clean Flat Design), and Providers (State Management). +- **`lib/core`**: Application-wide configurations like Themes, DI setup, and Constants. + +--- + +## ๐Ÿ”’ Privacy & Security + +* **No Central Server**: Your financial data is never stored on our servers. +* **Encrypted Sync**: Cloud sync happens directly between your device and your private Google Drive space. +* **Obfuscation**: Production builds are obfuscated using R8/ProGuard to protect the application logic. + +--- + +## ๐Ÿ“„ License +This project is for personal use and portfolio demonstration. See `LICENSE` for details. diff --git a/android/.gitignore b/android/.gitignore index be3943c..ffddf71 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -12,3 +12,8 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# Firebase credentials +app/google-services.json +google-services.json + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 03bc328..b788619 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,8 +7,24 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// Apply Google Services and Crashlytics only if google-services.json exists +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") + apply(plugin = "com.google.firebase.crashlytics") + println("Google Services and Crashlytics plugins applied") +} else { + println("google-services.json not found. Skipping Google Services and Crashlytics plugins") +} + +// Load signing properties (if available) +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(keystorePropertiesFile.inputStream()) +} + android { - namespace = "com.example.vent_expense_pro" + namespace = "com.digiventure.ventexpensepro" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -19,9 +37,19 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.vent_expense_pro" + applicationId = "com.digiventure.ventexpensepro" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -32,8 +60,26 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. + isMinifyEnabled = true + isShrinkResources = true + isDebuggable = false + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + ndk { + debugSymbolLevel = "FULL" // For Crashlytics native crash symbolication + } + } + debug { + isMinifyEnabled = false + isShrinkResources = false + isDebuggable = true signingConfig = signingConfigs.getByName("debug") } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..036e18f --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,97 @@ +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# VentExpensePro โ€” ProGuard / R8 Rules +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# โ”€โ”€ Flutter Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } +-dontwarn io.flutter.embedding.** + +# โ”€โ”€ Firebase Crashlytics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keepattributes SourceFile,LineNumberTable # Readable stack traces +-keep public class * extends java.lang.Exception # Keep custom exceptions +-keep class com.google.firebase.crashlytics.** { *; } +-dontwarn com.google.firebase.crashlytics.** + +# โ”€โ”€ Firebase Core โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** + +# โ”€โ”€ Google Play Services / Auth โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# โ”€โ”€ Google Drive API & HTTP Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class com.google.api.client.** { *; } +-keep class com.google.api.services.drive.** { *; } +-keep class com.google.http.** { *; } +-dontwarn com.google.api.client.** +-dontwarn com.google.api.services.** +-dontwarn com.google.http.** + +# โ”€โ”€ Gson (used by Google HTTP Client) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class com.google.gson.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# โ”€โ”€ SharedPreferences โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class io.flutter.plugins.sharedpreferences.** { *; } +-keep class androidx.datastore.** { *; } +-dontwarn androidx.datastore.** + +# โ”€โ”€ SQFlite (SQLite) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class com.tekartik.sqflite.** { *; } +-dontwarn com.tekartik.sqflite.** + +# โ”€โ”€ Path Provider โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class io.flutter.plugins.pathprovider.** { *; } + +# โ”€โ”€ Share Plus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class dev.fluttercommunity.plus.share.** { *; } +-dontwarn dev.fluttercommunity.plus.share.** + +# โ”€โ”€ PDF Generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class com.ril.pdf_box.** { *; } +-dontwarn com.ril.pdf_box.** + +# โ”€โ”€ Kotlin โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keep class kotlin.Metadata { *; } +-dontwarn kotlin.** +-dontwarn kotlinx.** + +# โ”€โ”€ Kotlin Serialization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# โ”€โ”€ General โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +-keepattributes *Annotation* +-keepattributes Exceptions +-keepattributes InnerClasses +-keepattributes Signature +-keepattributes EnclosingMethod + +# Suppress warnings for common third-party libraries +-dontwarn javax.annotation.** +-dontwarn org.codehaus.mojo.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** +-dontwarn org.apache.http.** +-dontwarn android.net.http.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 680d0d1..9675fa0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..9734e92 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..1b151c3 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..6e66082 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..6b1ff9b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..f15a48e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/key.properties.example b/android/key.properties.example new file mode 100644 index 0000000..86cb443 --- /dev/null +++ b/android/key.properties.example @@ -0,0 +1,8 @@ +## Release signing configuration for VentExpensePro +## Copy this file as `key.properties` and fill in your values. +## โš ๏ธ NEVER commit `key.properties` to version control! + +storePassword=your_keystore_password +keyPassword=your_key_password +keyAlias=your_key_alias +storeFile=/path/to/your/keystore.jks diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..c907600 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,6 +21,8 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.2" apply false + id("com.google.firebase.crashlytics") version "3.0.3" apply false } include(":app") diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f3e0bac --- /dev/null +++ b/build.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# VentExpensePro โ€” Build Script +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# +# Usage: +# ./build.sh +# +# Commands: +# clean Clean build artifacts +# debug Build debug APK +# apk Build release APK (fat โ€” all ABIs in one) +# apk-split Build release APK split per ABI (arm64, arm, x86_64) +# aab Build release App Bundle (for Play Store upload) +# install Build debug APK and install on connected device +# icons Regenerate launcher icons from 1024.png +# analyze Run Flutter static analysis +# test Run all unit tests +# all Build everything (debug APK + split APKs + AAB) +# +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$PROJECT_DIR/build/app/outputs" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +# โ”€โ”€ Colors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +info() { echo -e "${CYAN}โ„น $1${NC}"; } +success() { echo -e "${GREEN}โœ… $1${NC}"; } +warn() { echo -e "${YELLOW}โš ๏ธ $1${NC}"; } +error() { echo -e "${RED}โŒ $1${NC}"; exit 1; } + +header() { + echo "" + echo -e "${CYAN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" + echo "" +} + +# โ”€โ”€ Pre-flight checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +preflight() { + if ! command -v flutter &> /dev/null; then + error "Flutter not found in PATH. Please install Flutter first." + fi + + info "Running flutter pub get..." + cd "$PROJECT_DIR" + flutter pub get +} + +# โ”€โ”€ Commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +cmd_clean() { + header "๐Ÿงน Cleaning build artifacts" + cd "$PROJECT_DIR" + flutter clean + flutter pub get + success "Clean complete" +} + +cmd_debug() { + header "๐Ÿ”ง Building Debug APK" + preflight + flutter build apk --debug + success "Debug APK โ†’ build/app/outputs/flutter-apk/app-debug.apk" +} + +cmd_apk() { + header "๐Ÿ“ฆ Building Release APK (fat)" + preflight + check_signing + flutter build apk --release --obfuscate --split-debug-info=build/debug-info + success "Release APK โ†’ build/app/outputs/flutter-apk/app-release.apk" +} + +cmd_apk_split() { + header "๐Ÿ“ฆ Building Release APKs (split per ABI)" + preflight + check_signing + flutter build apk --release --split-per-abi --obfuscate --split-debug-info=build/debug-info + echo "" + success "Split APKs generated:" + echo " โ€ข app-arm64-v8a-release.apk (most modern devices)" + echo " โ€ข app-armeabi-v7a-release.apk (older 32-bit devices)" + echo " โ€ข app-x86_64-release.apk (emulators / Chromebooks)" + echo "" + echo " Location: build/app/outputs/flutter-apk/" +} + +cmd_aab() { + header "๐Ÿš€ Building Release App Bundle (Play Store)" + preflight + check_signing + flutter build appbundle --release --obfuscate --split-debug-info=build/debug-info + success "App Bundle โ†’ build/app/outputs/bundle/release/app-release.aab" + echo "" + info "Upload this .aab file to the Google Play Console." + info "Debug symbols are in build/debug-info/ (upload to Play Console for crash reports)." +} + +cmd_install() { + header "๐Ÿ“ฑ Building & Installing Debug APK" + preflight + + if ! adb devices | grep -q "device$"; then + error "No connected device/emulator found. Start one first." + fi + + flutter build apk --debug + adb install -r build/app/outputs/flutter-apk/app-debug.apk + success "Installed on device" +} + +cmd_icons() { + header "๐ŸŽจ Regenerating Launcher Icons" + cd "$PROJECT_DIR" + flutter pub get + dart run flutter_launcher_icons + success "Icons regenerated from 1024.png" +} + +cmd_analyze() { + header "๐Ÿ” Running Static Analysis" + cd "$PROJECT_DIR" + flutter analyze + success "Analysis complete" +} + +cmd_test() { + header "๐Ÿงช Running Tests" + cd "$PROJECT_DIR" + flutter test + success "All tests passed" +} + +cmd_all() { + header "๐Ÿ—๏ธ Building Everything" + cmd_debug + cmd_apk_split + cmd_aab + success "All builds complete!" +} + +# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +check_signing() { + local keyfile="$PROJECT_DIR/android/key.properties" + if [ ! -f "$keyfile" ]; then + warn "android/key.properties not found โ€” release build will use debug signing." + warn "For Play Store, create key.properties from key.properties.example." + echo "" + fi +} + +show_help() { + echo "" + echo "VentExpensePro Build Script" + echo "" + echo "Usage: ./build.sh " + echo "" + echo "Commands:" + echo " clean Clean build artifacts and re-fetch dependencies" + echo " debug Build debug APK" + echo " apk Build release APK (fat โ€” all ABIs in one)" + echo " apk-split Build release APK split per ABI" + echo " aab Build release App Bundle (for Play Store)" + echo " install Build debug APK and install on connected device" + echo " icons Regenerate launcher icons" + echo " analyze Run Flutter static analysis" + echo " test Run all unit tests" + echo " all Build everything (debug + split APKs + AAB)" + echo "" +} + +# โ”€โ”€ Entrypoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +case "${1:-}" in + clean) cmd_clean ;; + debug) cmd_debug ;; + apk) cmd_apk ;; + apk-split) cmd_apk_split ;; + aab) cmd_aab ;; + install) cmd_install ;; + icons) cmd_icons ;; + analyze) cmd_analyze ;; + test) cmd_test ;; + all) cmd_all ;; + *) show_help ;; +esac diff --git a/images/01_ledger_screen.png b/images/01_ledger_screen.png new file mode 100644 index 0000000..c68a25a Binary files /dev/null and b/images/01_ledger_screen.png differ diff --git a/images/02_accounts_screen.png b/images/02_accounts_screen.png new file mode 100644 index 0000000..a6af24f Binary files /dev/null and b/images/02_accounts_screen.png differ diff --git a/images/03_reports_screen.png b/images/03_reports_screen.png new file mode 100644 index 0000000..898a565 Binary files /dev/null and b/images/03_reports_screen.png differ diff --git a/images/04_quick_add_transaction.png b/images/04_quick_add_transaction.png new file mode 100644 index 0000000..d3a28f8 Binary files /dev/null and b/images/04_quick_add_transaction.png differ diff --git a/images/05_overflow_menu.png b/images/05_overflow_menu.png new file mode 100644 index 0000000..c8b7632 Binary files /dev/null and b/images/05_overflow_menu.png differ diff --git a/images/06_category_management.png b/images/06_category_management.png new file mode 100644 index 0000000..6414d86 Binary files /dev/null and b/images/06_category_management.png differ diff --git a/images/07_backup_sync.png b/images/07_backup_sync.png new file mode 100644 index 0000000..6213d8e Binary files /dev/null and b/images/07_backup_sync.png differ diff --git a/images/08_date_filter.png b/images/08_date_filter.png new file mode 100644 index 0000000..7bc7778 Binary files /dev/null and b/images/08_date_filter.png differ diff --git a/images/09_pdf_generation.png b/images/09_pdf_generation.png new file mode 100644 index 0000000..1bb176b Binary files /dev/null and b/images/09_pdf_generation.png differ diff --git a/images/10_pay_bill_sheet.png b/images/10_pay_bill_sheet.png new file mode 100644 index 0000000..195598c Binary files /dev/null and b/images/10_pay_bill_sheet.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c150896..57c3d74 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -372,7 +372,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.ventExpensePro; + PRODUCT_BUNDLE_IDENTIFIER = com.digiventure.ventexpensepro; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -388,7 +388,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.ventExpensePro.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.digiventure.ventexpensepro.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -405,7 +405,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.ventExpensePro.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.digiventure.ventexpensepro.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -420,7 +420,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.ventExpensePro.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.digiventure.ventexpensepro.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -431,7 +431,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -488,7 +488,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.ventExpensePro; + PRODUCT_BUNDLE_IDENTIFIER = com.digiventure.ventexpensepro; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -573,7 +573,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.ventExpensePro; + PRODUCT_BUNDLE_IDENTIFIER = com.digiventure.ventexpensepro; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..0a98ee8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..4b9d9b4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..9383249 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..82c302d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..2895d9e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..289d484 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..a3bd399 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..9383249 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..680d6ee 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..b3d03c7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..9b4c0f7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..c3bd396 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..044a391 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..c09f7fd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..b3d03c7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..22ec122 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..9734e92 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..6b1ff9b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..4ddaede 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..c9b27e4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..8718703 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 51be3e2..1f7b8c1 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Vent Expense Pro + VentExpensePro CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - vent_expense_pro + VentExpensePro CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/core/di/service_locator.dart b/lib/core/di/service_locator.dart index 05dcefe..b65c01f 100644 --- a/lib/core/di/service_locator.dart +++ b/lib/core/di/service_locator.dart @@ -19,7 +19,6 @@ import '../../domain/usecases/generate_report.dart'; import '../../domain/repositories/report_repository.dart'; import '../../data/repositories/report_repository_impl.dart'; import '../../data/datasources/pdf_report_service.dart'; -import '../../data/datasources/excel_report_service.dart'; /// Global service locator instance. final sl = GetIt.instance; @@ -41,11 +40,9 @@ Future initServiceLocator() async { // โ€” Reports โ€” sl.registerLazySingleton(() => PdfReportService()); - sl.registerLazySingleton(() => ExcelReportService()); sl.registerLazySingleton( () => ReportRepositoryImpl( pdfService: sl(), - excelService: sl(), ), ); diff --git a/lib/core/utils/currency_formatter.dart b/lib/core/utils/currency_formatter.dart index 372397f..cff03ef 100644 --- a/lib/core/utils/currency_formatter.dart +++ b/lib/core/utils/currency_formatter.dart @@ -6,44 +6,80 @@ class CurrencyFormatter { /// Formats [cents] as a currency string. /// - /// Example: `formatCents(1500000)` โ†’ `"Rp 1.500.000"` for IDR. + /// Uses the account's [currency] code to determine the symbol and locale. + /// Example: `formatCents(1500000, currency: 'USD')` โ†’ `"$15,000.00"`. static String formatCents(int cents, {String currency = 'IDR'}) { final format = NumberFormat.currency( locale: _locale(currency), symbol: _symbol(currency), - decimalDigits: currency == 'IDR' ? 0 : 2, + decimalDigits: _decimalDigits(currency), ); - // IDR uses whole units (no cents subdivision in practice). - final value = currency == 'IDR' ? cents.toDouble() : cents / 100.0; + final value = _decimalDigits(currency) == 0 + ? cents.toDouble() + : cents / 100.0; return format.format(value); } /// Formats [cents] without the currency symbol (just the number). static String formatCentsPlain(int cents, {String currency = 'IDR'}) { final format = NumberFormat.decimalPattern(_locale(currency)); - final value = currency == 'IDR' ? cents.toDouble() : cents / 100.0; + final value = _decimalDigits(currency) == 0 + ? cents.toDouble() + : cents / 100.0; return format.format(value); } + /// Returns the currency symbol for a given [currency] code. + static String symbol(String currency) => _symbol(currency); + + /// Number of decimal digits for this currency (0 for IDR/JPY/KRW, 2 for most). + static int decimalDigits(String currency) => _decimalDigits(currency); + + static int _decimalDigits(String currency) { + switch (currency) { + case 'IDR': + case 'JPY': + case 'KRW': + case 'VND': + return 0; + default: + return 2; + } + } + static String _locale(String currency) { switch (currency) { case 'IDR': return 'id_ID'; case 'USD': return 'en_US'; + case 'EUR': + return 'de_DE'; + case 'GBP': + return 'en_GB'; + case 'JPY': + return 'ja_JP'; + case 'KRW': + return 'ko_KR'; + case 'SGD': + return 'en_SG'; + case 'MYR': + return 'ms_MY'; default: return 'en_US'; } } static String _symbol(String currency) { - switch (currency) { - case 'IDR': - return 'Rp '; - case 'USD': - return '\$'; - default: - return currency; + // Use NumberFormat to resolve the symbol automatically when possible. + try { + return NumberFormat.simpleCurrency( + locale: _locale(currency), + name: currency, + ).currencySymbol; + } catch (_) { + // Fallback: just use the code itself (e.g. "BTC"). + return '$currency '; } } } diff --git a/lib/data/datasources/excel_report_service.dart b/lib/data/datasources/excel_report_service.dart deleted file mode 100644 index 048b1cc..0000000 --- a/lib/data/datasources/excel_report_service.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:io'; -import 'package:excel/excel.dart'; -import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../../domain/entities/account.dart'; -import '../../domain/entities/category.dart'; -import '../../domain/entities/enums.dart'; -import '../../domain/entities/transaction.dart'; -import '../models/category_model.dart'; - -class ExcelReportService { - Future generate({ - required List transactions, - required List accounts, - required List categories, - String? accountId, - DateTime? startDate, - DateTime? endDate, - }) async { - final excel = Excel.createExcel(); - final Sheet sheet = excel['Ledger Report']; - - final accountName = accountId != null - ? accounts.firstWhere((a) => a.id == accountId).name - : 'All accounts'; - - // Summary Header - sheet.appendRow([TextCellValue('VentExpense Pro Ledger Statement')]); - sheet.appendRow([TextCellValue('Account: $accountName')]); - sheet.appendRow([ - TextCellValue('Period: ${startDate != null ? DateFormat('dd/MM/yyyy').format(startDate) : 'Start'} - ${endDate != null ? DateFormat('dd/MM/yyyy').format(endDate) : 'Present'}') - ]); - sheet.appendRow([]); // Empty spacer - - // Table Header - sheet.appendRow([ - TextCellValue('Date'), - TextCellValue('Type'), - TextCellValue('Category'), - TextCellValue('Account'), - TextCellValue('To Account'), - TextCellValue('Note'), - TextCellValue('Amount'), - TextCellValue('Is Settlement'), - ]); - - // Data Rows - for (final t in transactions) { - final category = categories.firstWhere( - (c) => c.id == t.categoryId, - orElse: () => const CategoryModel(id: '?', name: 'Unknown', icon: '?'), - ); - final account = accounts.firstWhere((a) => a.id == t.accountId); - final toAccount = t.toAccountId != null - ? accounts.firstWhere((a) => a.id == t.toAccountId).name - : '-'; - - sheet.appendRow([ - TextCellValue(DateFormat('yyyy-MM-dd HH:mm').format(t.dateTime)), - TextCellValue(t.type.toString().split('.').last.toUpperCase()), - TextCellValue(category.name), - TextCellValue(account.name), - TextCellValue(toAccount), - TextCellValue(t.note ?? ''), - IntCellValue(t.type == TransactionType.expense ? -t.amount : t.amount), - TextCellValue(t.isSettlement ? 'Yes' : 'No'), - ]); - } - - final output = await getTemporaryDirectory(); - final fileName = 'vent_report_${DateTime.now().millisecondsSinceEpoch}.xlsx'; - final path = '${output.path}/$fileName'; - - final fileBytes = excel.save(); - if (fileBytes != null) { - await File(path).writeAsBytes(fileBytes); - } - - return path; - } -} diff --git a/lib/data/datasources/local_database.dart b/lib/data/datasources/local_database.dart index 957ab50..36b501e 100644 --- a/lib/data/datasources/local_database.dart +++ b/lib/data/datasources/local_database.dart @@ -18,7 +18,32 @@ class LocalDatabase { final dbPath = await getDatabasesPath(); final path = '$dbPath/$_dbName'; - return openDatabase(path, version: _dbVersion, onCreate: _onCreate); + return openDatabase( + path, + version: _dbVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + } + + /// Handles sequential schema migrations. + /// + /// Each version bump gets its own block. Migrations run in order, + /// so a user jumping from v1 โ†’ v3 will execute both v1โ†’v2 and v2โ†’v3. + /// + /// Example โ€” when you need to add a column in the future: + /// ```dart + /// if (oldVersion < 2) { + /// await db.execute('ALTER TABLE accounts ADD COLUMN color TEXT'); + /// } + /// if (oldVersion < 3) { + /// await db.execute('ALTER TABLE transactions ADD COLUMN tag TEXT'); + /// } + /// ``` + static Future _onUpgrade( + Database db, int oldVersion, int newVersion) async { + // โ€” Future migrations go here โ€” + // if (oldVersion < 2) { ... } } static Future _onCreate(Database db, int version) async { diff --git a/lib/data/datasources/pdf_report_service.dart b/lib/data/datasources/pdf_report_service.dart index 043816b..0c623a5 100644 --- a/lib/data/datasources/pdf_report_service.dart +++ b/lib/data/datasources/pdf_report_service.dart @@ -48,6 +48,63 @@ class PdfReportService { ? '${formatter.format(startDate)} - ${formatter.format(endDate)}' : 'All Time'; + // Calculate Statistics + double totalIncome = 0; + double totalExpense = 0; + final Map expenseByCategory = {}; + + for (final t in transactions) { + if (t.type == TransactionType.income) { + totalIncome += t.amount; + } else if (t.type == TransactionType.expense) { + totalExpense += t.amount; + expenseByCategory[t.categoryId] = (expenseByCategory[t.categoryId] ?? 0) + t.amount; + } + } + final netBalance = totalIncome - totalExpense; + + // Prepare Chart Data + final List chartDatasets = []; + final List chartColors = [ + _inkBlue, _inkGreen, _stampRed, PdfColors.amber700, PdfColors.teal, PdfColors.purple, + ]; + int colorIndex = 0; + + final List legendWidgets = []; + + expenseByCategory.forEach((categoryId, amount) { + final category = categories.firstWhere( + (c) => c.id == categoryId, + orElse: () => const CategoryModel(id: '?', name: 'Unknown', icon: '?'), + ); + final color = chartColors[colorIndex % chartColors.length]; + chartDatasets.add( + pw.PieDataSet( + value: amount, + color: color, + legend: null, // Disable built-in legend to avoid squishing + ), + ); + legendWidgets.add( + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 4), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + mainAxisSize: pw.MainAxisSize.min, + children: [ + pw.Container(width: 8, height: 8, decoration: pw.BoxDecoration(color: color, shape: pw.BoxShape.circle)), + pw.SizedBox(width: 8), + pw.Text( + '${category.name} (${currencyFormatter.format(amount)})', + style: pw.TextStyle(font: loraRegular, fontSize: 9, color: _inkDark), + ), + ], + ), + ), + ); + colorIndex++; + }); + pdf.addPage( pw.MultiPage( pageTheme: pw.PageTheme( @@ -116,6 +173,81 @@ class PdfReportService { ), ], ), + pw.SizedBox(height: 16), + + // Statistics & Chart Section + if (transactions.isNotEmpty) + pw.Container( + padding: const pw.EdgeInsets.all(16), + decoration: pw.BoxDecoration( + color: PdfColors.white, + border: pw.Border.all(color: _inkLight, width: 0.5), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)), + ), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Summary Numbers + pw.Expanded( + flex: 2, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _label('TOTAL INCOME', loraBold), + pw.Text('+${currencyFormatter.format(totalIncome)}', style: pw.TextStyle(font: monoRegular, fontSize: 14, color: _inkGreen)), + pw.SizedBox(height: 12), + _label('TOTAL EXPENSE', loraBold), + pw.Text('-${currencyFormatter.format(totalExpense)}', style: pw.TextStyle(font: monoRegular, fontSize: 14, color: _stampRed)), + pw.SizedBox(height: 12), + pw.Divider(color: _inkLight, thickness: 0.5), + pw.SizedBox(height: 8), + _label('NET BALANCE', loraBold), + pw.Text( + '${netBalance >= 0 ? '+' : ''}${currencyFormatter.format(netBalance)}', + style: pw.TextStyle( + font: monoRegular, + fontSize: 16, + fontWeight: pw.FontWeight.bold, + color: netBalance >= 0 ? _inkGreen : _stampRed, + ), + ), + ], + ), + ), + pw.SizedBox(width: 24), + // Pie Chart (Expense Breakdown) + if (expenseByCategory.isNotEmpty) ...[ + pw.Expanded( + flex: 1, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + _label('BREAKDOWN', loraBold), + pw.SizedBox(height: 8), + pw.SizedBox( + height: 80, + width: 80, + child: pw.Chart( + grid: pw.PieGrid(), + datasets: chartDatasets, + ), + ), + ], + ), + ), + pw.SizedBox(width: 16), + pw.Expanded( + flex: 2, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.center, + children: legendWidgets, + ), + ), + ], + ], + ), + ), pw.SizedBox(height: 24), ], ), diff --git a/lib/data/repositories/report_repository_impl.dart b/lib/data/repositories/report_repository_impl.dart index 4d2e49b..6548697 100644 --- a/lib/data/repositories/report_repository_impl.dart +++ b/lib/data/repositories/report_repository_impl.dart @@ -1,4 +1,3 @@ -import '../datasources/excel_report_service.dart'; import '../datasources/pdf_report_service.dart'; import '../../domain/entities/account.dart'; import '../../domain/entities/category.dart'; @@ -7,11 +6,9 @@ import '../../domain/repositories/report_repository.dart'; class ReportRepositoryImpl implements ReportRepository { final PdfReportService pdfService; - final ExcelReportService excelService; ReportRepositoryImpl({ required this.pdfService, - required this.excelService, }); @override @@ -32,23 +29,4 @@ class ReportRepositoryImpl implements ReportRepository { endDate: endDate, ); } - - @override - Future generateExcel({ - required List transactions, - required List accounts, - required List categories, - String? accountId, - DateTime? startDate, - DateTime? endDate, - }) { - return excelService.generate( - transactions: transactions, - accounts: accounts, - categories: categories, - accountId: accountId, - startDate: startDate, - endDate: endDate, - ); - } } diff --git a/lib/domain/repositories/report_repository.dart b/lib/domain/repositories/report_repository.dart index 2940291..ead4bec 100644 --- a/lib/domain/repositories/report_repository.dart +++ b/lib/domain/repositories/report_repository.dart @@ -14,13 +14,4 @@ abstract class ReportRepository { DateTime? endDate, }); - /// Generates an Excel spreadsheet. Returns the file path of the generated report. - Future generateExcel({ - required List transactions, - required List accounts, - required List categories, - String? accountId, - DateTime? startDate, - DateTime? endDate, - }); } diff --git a/lib/domain/usecases/generate_report.dart b/lib/domain/usecases/generate_report.dart index 49dc8d1..ee87c64 100644 --- a/lib/domain/usecases/generate_report.dart +++ b/lib/domain/usecases/generate_report.dart @@ -17,7 +17,7 @@ class GenerateReport { required this.categoryRepository, }); - /// Generates a report of the specified [type] ('pdf' or 'excel'). + /// Generates a report of the specified [type] ('pdf'). Future call({ required String type, String? accountId, @@ -44,24 +44,13 @@ class GenerateReport { }).toList(); // 3. Delegate to repository - if (type.toLowerCase() == 'pdf') { - return reportRepository.generatePdf( - transactions: filteredTransactions, - accounts: accounts, - categories: categories, - accountId: accountId, - startDate: startDate, - endDate: endDate, - ); - } else { - return reportRepository.generateExcel( - transactions: filteredTransactions, - accounts: accounts, - categories: categories, - accountId: accountId, - startDate: startDate, - endDate: endDate, - ); - } + return reportRepository.generatePdf( + transactions: filteredTransactions, + accounts: accounts, + categories: categories, + accountId: accountId, + startDate: startDate, + endDate: endDate, + ); } } diff --git a/lib/domain/value_objects/money.dart b/lib/domain/value_objects/money.dart index 8ff022e..621206e 100644 --- a/lib/domain/value_objects/money.dart +++ b/lib/domain/value_objects/money.dart @@ -3,13 +3,13 @@ import 'package:intl/intl.dart'; /// An immutable representation of a monetary amount. /// -/// Internally stored as an [int] in the smallest currency unit (e.g. cents) +/// Internally stored as an [int] in the smallest currency unit /// to avoid floating-point precision issues. class Money extends Equatable { - /// The amount in the smallest currency unit (e.g. 15000 = Rp 150.00). + /// The amount in the smallest currency unit. final int cents; - /// ISO 4217 currency code. + /// ISO 4217 currency code (e.g. 'IDR', 'USD', 'EUR'). final String currency; const Money({required this.cents, this.currency = 'IDR'}); @@ -22,14 +22,15 @@ class Money extends Equatable { /// The amount as a double (e.g. 15000 โ†’ 150.00). double get asDouble => cents / 100.0; - /// Formatted display string (e.g. "Rp 150.00" or "$1,500.00"). + /// Formatted display string using the currency's symbol and locale. String get formatted { final format = NumberFormat.currency( locale: _localeForCurrency(currency), symbol: _symbolForCurrency(currency), - decimalDigits: currency == 'IDR' ? 0 : 2, + decimalDigits: _decimalDigits(currency), ); - return format.format(currency == 'IDR' ? cents : asDouble); + final value = _decimalDigits(currency) == 0 ? cents : asDouble; + return format.format(value); } // โ€” Arithmetic โ€” @@ -52,25 +53,43 @@ class Money extends Equatable { // โ€” Helpers โ€” + static int _decimalDigits(String currency) { + switch (currency) { + case 'IDR': + case 'JPY': + case 'KRW': + case 'VND': + return 0; + default: + return 2; + } + } + static String _localeForCurrency(String currency) { switch (currency) { case 'IDR': return 'id_ID'; case 'USD': return 'en_US'; + case 'EUR': + return 'de_DE'; + case 'GBP': + return 'en_GB'; + case 'JPY': + return 'ja_JP'; default: return 'en_US'; } } static String _symbolForCurrency(String currency) { - switch (currency) { - case 'IDR': - return 'Rp '; - case 'USD': - return '\$'; - default: - return currency; + try { + return NumberFormat.simpleCurrency( + locale: _localeForCurrency(currency), + name: currency, + ).currencySymbol; + } catch (_) { + return '$currency '; } } diff --git a/lib/main.dart b/lib/main.dart index 4bf5dcf..29be99e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; import 'core/di/service_locator.dart'; import 'core/theme/app_colors.dart'; @@ -19,6 +22,7 @@ import 'domain/usecases/sync_data.dart'; import 'domain/usecases/generate_report.dart'; import 'presentation/providers/account_provider.dart'; import 'presentation/providers/category_provider.dart'; +import 'presentation/providers/currency_provider.dart'; import 'presentation/providers/reports_provider.dart'; import 'presentation/providers/sync_provider.dart'; import 'presentation/providers/transaction_provider.dart'; @@ -32,6 +36,24 @@ import 'presentation/widgets/sync_settings_card.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + try { + // Initialize Firebase + await Firebase.initializeApp(); + + // Pass all uncaught "fatal" errors from the framework to Crashlytics + FlutterError.onError = (errorDetails) { + FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); + }; + + // Pass all uncaught asynchronous errors that aren't handled by the Flutter framework to Crashlytics + PlatformDispatcher.instance.onError = (error, stack) { + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + return true; + }; + } catch (e) { + debugPrint('Firebase not initialized: $e'); + } + // Initialize dependency injection await initServiceLocator(); @@ -64,6 +86,9 @@ class VentExpenseApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => CategoryProvider(sl()), ), + ChangeNotifierProvider( + create: (_) => CurrencyProvider()..loadCurrency(), + ), ChangeNotifierProvider( create: (_) => SyncProvider(sl()), ), @@ -92,7 +117,7 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int _currentIndex = 0; - static const _screens = [LedgerScreen(), AccountsScreen(), ReportsScreen()]; + static const _titles = ['Ledger', 'Accounts', 'Reports']; @@ -122,21 +147,31 @@ class _HomeShellState extends State { ], ), ), - const PopupMenuItem( - value: 'sync', - child: Row( - children: [ - Icon(Icons.cloud_outlined, size: 20), - SizedBox(width: 8), - Text('Backup & Sync'), - ], - ), - ), + // TODO: Re-enable when Backup & Sync is ready + // const PopupMenuItem( + // value: 'sync', + // child: Row( + // children: [ + // Icon(Icons.cloud_outlined, size: 20), + // SizedBox(width: 8), + // Text('Backup & Sync'), + // ], + // ), + // ), ], ), ], ), - body: _screens[_currentIndex], + body: RepaintBoundary( + child: IndexedStack( + index: _currentIndex, + children: const [ + LedgerScreen(), + AccountsScreen(), + ReportsScreen(), + ], + ), + ), bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, onTap: (index) => setState(() => _currentIndex = index), diff --git a/lib/presentation/painters/paper_background.dart b/lib/presentation/painters/paper_background.dart index ed9b2da..807c5c6 100644 --- a/lib/presentation/painters/paper_background.dart +++ b/lib/presentation/painters/paper_background.dart @@ -54,6 +54,16 @@ class PaperBackground extends StatelessWidget { @override Widget build(BuildContext context) { - return CustomPaint(painter: PaperBackgroundPainter(), child: child); + return Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + child: CustomPaint( + painter: PaperBackgroundPainter(), + ), + ), + child, + ], + ); } } diff --git a/lib/presentation/providers/currency_provider.dart b/lib/presentation/providers/currency_provider.dart new file mode 100644 index 0000000..3217ade --- /dev/null +++ b/lib/presentation/providers/currency_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../core/utils/currency_formatter.dart'; + +/// Supported currencies with display labels. +class SupportedCurrency { + final String code; + final String label; + + const SupportedCurrency(this.code, this.label); +} + +/// App-wide currency setting, persisted via SharedPreferences. +class CurrencyProvider extends ChangeNotifier { + static const _key = 'app_currency'; + + String _currency = 'IDR'; + + /// All supported currencies. + static const supported = [ + SupportedCurrency('IDR', 'IDR โ€” Indonesian Rupiah'), + SupportedCurrency('USD', 'USD โ€” US Dollar'), + SupportedCurrency('EUR', 'EUR โ€” Euro'), + SupportedCurrency('GBP', 'GBP โ€” British Pound'), + SupportedCurrency('SGD', 'SGD โ€” Singapore Dollar'), + SupportedCurrency('MYR', 'MYR โ€” Malaysian Ringgit'), + SupportedCurrency('JPY', 'JPY โ€” Japanese Yen'), + SupportedCurrency('KRW', 'KRW โ€” Korean Won'), + SupportedCurrency('AUD', 'AUD โ€” Australian Dollar'), + SupportedCurrency('THB', 'THB โ€” Thai Baht'), + ]; + + // โ€” Getters โ€” + + /// The active currency code (e.g. 'IDR', 'USD'). + String get currency => _currency; + + /// The currency symbol (e.g. 'Rp', '$', 'โ‚ฌ'). + String get symbol => CurrencyFormatter.symbol(_currency); + + // โ€” Actions โ€” + + /// Loads the saved currency from SharedPreferences. + Future loadCurrency() async { + final prefs = await SharedPreferences.getInstance(); + _currency = prefs.getString(_key) ?? 'IDR'; + notifyListeners(); + } + + /// Sets and persists a new global currency. + Future setCurrency(String code) async { + if (_currency == code) return; + _currency = code; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, code); + } +} diff --git a/lib/presentation/providers/transaction_provider.dart b/lib/presentation/providers/transaction_provider.dart index 41baa42..f324a78 100644 --- a/lib/presentation/providers/transaction_provider.dart +++ b/lib/presentation/providers/transaction_provider.dart @@ -27,6 +27,13 @@ class TransactionProvider extends ChangeNotifier { /// Active date filter. Null means "show all". DateTimeRange? _dateFilter; + // โ€” Cached Computations โ€” + int _todaysSpending = 0; + int _thisMonthsSpending = 0; + List _filteredTransactions = []; + Map> _filteredGroupedByDate = {}; + final Map> _groupedByDate = {}; + // โ€” Getters โ€” List get transactions => _transactions; @@ -35,32 +42,62 @@ class TransactionProvider extends ChangeNotifier { String? get error => _error; DateTimeRange? get dateFilter => _dateFilter; - // โ€” Quick Stats โ€” - - int get todaysSpending { - final now = DateTime.now(); - return _transactions - .where((t) => - t.type == TransactionType.expense && - t.dateTime.year == now.year && - t.dateTime.month == now.month && - t.dateTime.day == now.day) - .fold(0, (sum, t) => sum + t.amount); + int get todaysSpending => _todaysSpending; + int get thisMonthsSpending => _thisMonthsSpending; + List get filteredTransactions => _filteredTransactions; + Map> get filteredGroupedByDate => _filteredGroupedByDate; + Map> get groupedByDate => _groupedByDate; + + /// Returns a category by [id] from the in-memory list, or `null`. + Category? getCategoryById(String id) { + try { + return _categories.firstWhere((c) => c.id == id); + } catch (_) { + return null; + } } - int get thisMonthsSpending { + // โ€” Recomputation Logic โ€” + + void _recomputeStats() { final now = DateTime.now(); - return _transactions - .where((t) => - t.type == TransactionType.expense && - t.dateTime.year == now.year && - t.dateTime.month == now.month) - .fold(0, (sum, t) => sum + t.amount); + int todaySum = 0; + int thisMonthSum = 0; + + _groupedByDate.clear(); + + for (final txn in _transactions) { + if (txn.type == TransactionType.expense) { + if (txn.dateTime.year == now.year && txn.dateTime.month == now.month) { + thisMonthSum += txn.amount; + if (txn.dateTime.day == now.day) { + todaySum += txn.amount; + } + } + } + + final dateKey = DateTime( + txn.dateTime.year, + txn.dateTime.month, + txn.dateTime.day, + ); + _groupedByDate.putIfAbsent(dateKey, () => []).add(txn); + } + + _todaysSpending = todaySum; + _thisMonthsSpending = thisMonthSum; + _recomputeFiltered(); } - /// Transactions filtered by the active date range (or all if no filter). - List get filteredTransactions { - if (_dateFilter == null) return _transactions; + void _recomputeFiltered() { + _filteredGroupedByDate.clear(); + + if (_dateFilter == null) { + _filteredTransactions = List.from(_transactions); + _filteredGroupedByDate = Map.from(_groupedByDate); + return; + } + final start = DateTime( _dateFilter!.start.year, _dateFilter!.start.month, @@ -72,46 +109,18 @@ class TransactionProvider extends ChangeNotifier { _dateFilter!.end.day, 23, 59, 59, ); - return _transactions - .where((t) => - !t.dateTime.isBefore(start) && !t.dateTime.isAfter(end)) - .toList(); - } - /// Filtered transactions grouped by date (for the receipt feed). - Map> get filteredGroupedByDate { - final grouped = >{}; - for (final txn in filteredTransactions) { - final dateKey = DateTime( - txn.dateTime.year, - txn.dateTime.month, - txn.dateTime.day, - ); - grouped.putIfAbsent(dateKey, () => []).add(txn); - } - return grouped; - } + _filteredTransactions = _transactions + .where((t) => !t.dateTime.isBefore(start) && !t.dateTime.isAfter(end)) + .toList(); - /// Transactions grouped by date (unfiltered โ€” kept for backwards compat). - Map> get groupedByDate { - final grouped = >{}; - for (final txn in _transactions) { + for (final txn in _filteredTransactions) { final dateKey = DateTime( txn.dateTime.year, txn.dateTime.month, txn.dateTime.day, ); - grouped.putIfAbsent(dateKey, () => []).add(txn); - } - return grouped; - } - - /// Returns a category by [id] from the in-memory list, or `null`. - Category? getCategoryById(String id) { - try { - return _categories.firstWhere((c) => c.id == id); - } catch (_) { - return null; + _filteredGroupedByDate.putIfAbsent(dateKey, () => []).add(txn); } } @@ -120,12 +129,14 @@ class TransactionProvider extends ChangeNotifier { /// Sets the date filter and notifies listeners. void setDateFilter(DateTimeRange range) { _dateFilter = range; + _recomputeFiltered(); notifyListeners(); } /// Clears the date filter (show all). void clearDateFilter() { _dateFilter = null; + _recomputeFiltered(); notifyListeners(); } @@ -140,6 +151,7 @@ class TransactionProvider extends ChangeNotifier { try { _transactions = await _transactionRepository.getAll(); _categories = await _categoryRepository.getAll(); + _recomputeStats(); } catch (e) { _error = e.toString(); } finally { @@ -156,6 +168,7 @@ class TransactionProvider extends ChangeNotifier { try { _transactions = await _transactionRepository.getAll(); + _recomputeStats(); } catch (e) { _error = e.toString(); } finally { diff --git a/lib/presentation/screens/accounts_screen.dart b/lib/presentation/screens/accounts_screen.dart index 923cbd1..2fb78fe 100644 --- a/lib/presentation/screens/accounts_screen.dart +++ b/lib/presentation/screens/accounts_screen.dart @@ -8,6 +8,7 @@ import '../../domain/entities/enums.dart'; import '../../domain/value_objects/money.dart'; import '../painters/paper_background.dart'; import '../providers/account_provider.dart'; +import '../providers/currency_provider.dart'; import '../providers/transaction_provider.dart'; import '../widgets/account_card.dart'; import '../widgets/add_edit_account_sheet.dart'; @@ -28,7 +29,8 @@ class _AccountsScreenState extends State { super.initState(); // Load accounts on first build WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().loadAccounts(); + final accProv = context.read(); + if (accProv.accounts.isEmpty) accProv.loadAccounts(); }); } @@ -37,20 +39,33 @@ class _AccountsScreenState extends State { return PaperBackground( child: Stack( children: [ - Consumer( - builder: (context, provider, _) { - if (provider.isLoading && provider.accounts.isEmpty) { - return const Center( - child: CircularProgressIndicator(color: AppColors.inkBlue), - ); - } - - if (provider.accounts.isEmpty) { - return _buildEmptyState(); - } - - return _buildAccountsList(provider); - }, + Column( + children: [ + // โ€” Currency Selector (always visible) โ€” + const SizedBox(height: 8), + _buildCurrencySelector(), + const SizedBox(height: 8), + + // โ€” Account content โ€” + Expanded( + child: Consumer( + builder: (context, provider, _) { + if (provider.isLoading && provider.accounts.isEmpty) { + return const Center( + child: CircularProgressIndicator( + color: AppColors.inkBlue), + ); + } + + if (provider.accounts.isEmpty) { + return _buildEmptyState(); + } + + return _buildAccountsList(provider); + }, + ), + ), + ], ), // โ€” FAB โ€” @@ -223,6 +238,108 @@ class _AccountsScreenState extends State { // โ€” Sheet & Dialog Helpers โ€” + Widget _buildCurrencySelector() { + return Consumer2( + builder: (context, currencyProv, accProv, _) { + final isLocked = accProv.accounts.isNotEmpty; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: AppColors.paperElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.divider, width: 0.5), + ), + child: Row( + children: [ + Icon( + isLocked ? Icons.lock_outline : Icons.currency_exchange, + size: 18, + color: isLocked ? AppColors.disabled : AppColors.inkBlue, + ), + const SizedBox(width: 10), + Text( + 'CURRENCY', + style: AppTypography.label.copyWith( + letterSpacing: 1.5, + color: AppColors.inkLight, + ), + ), + const Spacer(), + if (isLocked) + // Show the currency as static text when locked + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Currency is locked after adding accounts. ' + 'Delete all accounts to change currency.', + ), + backgroundColor: AppColors.inkLight, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + currencyProv.currency, + style: AppTypography.bodyMedium.copyWith( + color: AppColors.disabled, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.info_outline, + size: 14, + color: AppColors.disabled, + ), + ], + ), + ) + else + // Editable dropdown when no accounts exist + DropdownButtonHideUnderline( + child: DropdownButton( + value: currencyProv.currency, + isDense: true, + style: AppTypography.bodyMedium.copyWith( + color: AppColors.inkDark, + fontWeight: FontWeight.w600, + ), + icon: const Icon( + Icons.arrow_drop_down, + color: AppColors.inkBlue, + ), + items: CurrencyProvider.supported.map((c) { + return DropdownMenuItem( + value: c.code, + child: Text(c.label), + ); + }).toList(), + onChanged: (code) { + if (code != null) { + currencyProv.setCurrency(code); + } + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + Future _showAddSheet(BuildContext context) async { final provider = context.read(); final result = await showModalBottomSheet( diff --git a/lib/presentation/screens/ledger_screen.dart b/lib/presentation/screens/ledger_screen.dart index 6541528..b8b1af5 100644 --- a/lib/presentation/screens/ledger_screen.dart +++ b/lib/presentation/screens/ledger_screen.dart @@ -30,8 +30,11 @@ class _LedgerScreenState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().loadAll(); - context.read().loadAccounts(); + final txnProv = context.read(); + final accProv = context.read(); + + if (txnProv.transactions.isEmpty) txnProv.loadAll(); + if (accProv.accounts.isEmpty) accProv.loadAccounts(); }); } @@ -327,7 +330,7 @@ class _LedgerScreenState extends State { final picked = await showDateRangePicker( context: context, firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 1)), + lastDate: DateTime(DateTime.now().year + 5), initialDateRange: provider.dateFilter, builder: (context, child) { return Theme( diff --git a/lib/presentation/screens/reports_screen.dart b/lib/presentation/screens/reports_screen.dart index d7afab3..4359155 100644 --- a/lib/presentation/screens/reports_screen.dart +++ b/lib/presentation/screens/reports_screen.dart @@ -5,9 +5,12 @@ import 'package:share_plus/share_plus.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_typography.dart'; +import '../../domain/entities/enums.dart'; import '../painters/paper_background.dart'; import '../providers/account_provider.dart'; import '../providers/reports_provider.dart'; +import '../providers/transaction_provider.dart'; +import 'package:fl_chart/fl_chart.dart'; /// The reports screen โ€” PDF / Excel generation and viewing. class ReportsScreen extends StatelessWidget { @@ -18,8 +21,11 @@ class ReportsScreen extends StatelessWidget { return PaperBackground( child: Consumer2( builder: (context, reportsProvider, accountProvider, child) { + final transactionProvider = context.watch(); + final DateFormat formatter = DateFormat('dd MMM yyyy'); - final String dateRangeLabel = reportsProvider.startDate != null && + final String dateRangeLabel = + reportsProvider.startDate != null && reportsProvider.endDate != null ? '${formatter.format(reportsProvider.startDate!)} - ${formatter.format(reportsProvider.endDate!)}' : 'All Time'; @@ -36,14 +42,16 @@ class ReportsScreen extends StatelessWidget { const SizedBox(height: 8), Text( 'Generate bank-ready statements and spreadsheets for your records.', - style: AppTypography.bodyMedium.copyWith(color: AppColors.inkLight), + style: AppTypography.bodyMedium.copyWith( + color: AppColors.inkLight, + ), ), const SizedBox(height: 32), // โ€” Filter Section โ€” _buildSectionTitle('REPORT FILTERS'), const SizedBox(height: 12), - + // Date Range Selector _buildFilterTile( context, @@ -52,7 +60,8 @@ class ReportsScreen extends StatelessWidget { onTap: () async { final range = await showDateRangePicker( context: context, - initialDateRange: reportsProvider.startDate != null && + initialDateRange: + reportsProvider.startDate != null && reportsProvider.endDate != null ? DateTimeRange( start: reportsProvider.startDate!, @@ -60,7 +69,7 @@ class ReportsScreen extends StatelessWidget { ) : null, firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 1)), + lastDate: DateTime(DateTime.now().year + 5), builder: (context, child) { return Theme( data: Theme.of(context).copyWith( @@ -88,41 +97,45 @@ class ReportsScreen extends StatelessWidget { value: reportsProvider.selectedAccountId == null ? 'All Accounts' : accountProvider.accounts - .firstWhere((a) => a.id == reportsProvider.selectedAccountId) - .name, + .firstWhere( + (a) => a.id == reportsProvider.selectedAccountId, + ) + .name, onTap: () { - _showAccountSelector(context, accountProvider, reportsProvider); + _showAccountSelector( + context, + accountProvider, + reportsProvider, + ); }, ), const SizedBox(height: 40), - // โ€” Action Section โ€” - _buildSectionTitle('EXPORT FORMATS'), - const SizedBox(height: 16), + // โ€” Statistics & Action Section โ€” + _buildStatisticsCard( + context, + reportsProvider, + transactionProvider, + ), + const SizedBox(height: 32), if (reportsProvider.status == ReportStatus.loading) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 20), - child: CircularProgressIndicator(color: AppColors.inkBlue), + child: CircularProgressIndicator( + color: AppColors.inkBlue, + ), ), ) - else ...[ + else _buildExportButton( context, label: 'Generate PDF Statement', icon: Icons.picture_as_pdf_outlined, onTap: () => _generate(context, reportsProvider, 'pdf'), ), - const SizedBox(height: 12), - _buildExportButton( - context, - label: 'Export to Excel (.xlsx)', - icon: Icons.table_chart_outlined, - onTap: () => _generate(context, reportsProvider, 'excel'), - ), - ], if (reportsProvider.status == ReportStatus.success) ...[ const SizedBox(height: 32), @@ -131,7 +144,9 @@ class ReportsScreen extends StatelessWidget { decoration: BoxDecoration( color: AppColors.paperElevated, borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.inkGreen.withValues(alpha: 0.2)), + border: Border.all( + color: AppColors.inkGreen.withValues(alpha: 0.2), + ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), @@ -144,7 +159,11 @@ class ReportsScreen extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.check_circle, color: AppColors.inkGreen, size: 24), + const Icon( + Icons.check_circle, + color: AppColors.inkGreen, + size: 24, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -160,7 +179,9 @@ class ReportsScreen extends StatelessWidget { ), Text( 'Report generated successfully.', - style: AppTypography.bodySmall.copyWith(color: AppColors.inkLight), + style: AppTypography.bodySmall.copyWith( + color: AppColors.inkLight, + ), ), ], ), @@ -173,12 +194,14 @@ class ReportsScreen extends StatelessWidget { height: 50, child: ElevatedButton.icon( onPressed: () async { - final box = context.findRenderObject() as RenderBox?; + final box = + context.findRenderObject() as RenderBox?; // ignore: deprecated_member_use await Share.shareXFiles( [XFile(reportsProvider.generatedFilePath!)], text: 'VentExpense Report', - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ); }, icon: const Icon(Icons.share, size: 20), @@ -186,7 +209,9 @@ class ReportsScreen extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: AppColors.inkBlue, foregroundColor: AppColors.paper, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), elevation: 0, ), ), @@ -200,7 +225,9 @@ class ReportsScreen extends StatelessWidget { const SizedBox(height: 24), Text( 'Error: ${reportsProvider.errorMessage}', - style: AppTypography.bodySmall.copyWith(color: AppColors.stampRed), + style: AppTypography.bodySmall.copyWith( + color: AppColors.stampRed, + ), ), ], ], @@ -211,6 +238,211 @@ class ReportsScreen extends StatelessWidget { ); } + Widget _buildStatisticsCard( + BuildContext context, + ReportsProvider rProvider, + TransactionProvider tProvider, + ) { + if (rProvider.status == ReportStatus.loading || tProvider.isLoading) { + return const SizedBox.shrink(); + } + + final transactions = tProvider.transactions.where((t) { + final matchAccount = + rProvider.selectedAccountId == null || + t.accountId == rProvider.selectedAccountId; + final matchDate = + rProvider.startDate == null || + rProvider.endDate == null || + (!t.dateTime.isBefore(rProvider.startDate!) && + !t.dateTime.isAfter( + rProvider.endDate!.add(const Duration(days: 1)), + )); + return matchAccount && matchDate; + }).toList(); + + if (transactions.isEmpty) { + return Container( + padding: const EdgeInsets.all(24), + alignment: Alignment.center, + child: Text( + 'No transactions found for this period.', + style: AppTypography.bodyMedium.copyWith(color: AppColors.inkLight), + ), + ); + } + + double totalIncome = 0; + double totalExpense = 0; + final Map expenseByCategory = {}; + + for (var t in transactions) { + if (t.type == TransactionType.income) { + totalIncome += t.amount; + } else if (t.type == TransactionType.expense) { + totalExpense += t.amount; + expenseByCategory[t.categoryId] = + (expenseByCategory[t.categoryId] ?? 0) + + (t.amount as num).toDouble(); + } + } + final netBalance = totalIncome - totalExpense; + + final chartColors = [ + AppColors.inkBlue, + AppColors.inkGreen, + AppColors.stampRed, + Colors.amber[700]!, + Colors.teal, + Colors.purple, + ]; + int colorIndex = 0; + final List pieSections = []; + final List legendWidgets = []; + final currencyFormatter = NumberFormat.currency( + symbol: '', + decimalDigits: 0, + ); + + expenseByCategory.forEach((catId, amount) { + final category = tProvider.getCategoryById(catId); + final String categoryName = + (category != null && category.name != 'Unknown') + ? category.name + : 'Unknown ($catId)'; + + final color = chartColors[colorIndex % chartColors.length]; + + final percent = (amount / totalExpense * 100).toStringAsFixed(1); + + pieSections.add( + PieChartSectionData( + color: color, + value: amount, + title: '$percent%', + titleStyle: const TextStyle( + fontSize: 8, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + radius: 16, // slightly thicker doughnut to fit title + ), + ); + legendWidgets.add( + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$categoryName (${currencyFormatter.format(amount)})', + style: AppTypography.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + colorIndex++; + }); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.paperElevated, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.divider, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('STATISTICS SUMMARY'), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total Income', style: AppTypography.bodyMedium), + Text( + '+${currencyFormatter.format(totalIncome)}', + style: AppTypography.titleMedium.copyWith( + color: AppColors.inkGreen, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total Expense', style: AppTypography.bodyMedium), + Text( + '-${currencyFormatter.format(totalExpense)}', + style: AppTypography.titleMedium.copyWith( + color: AppColors.stampRed, + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Net Balance', style: AppTypography.titleMedium), + Text( + '${netBalance >= 0 ? '+' : ''}${currencyFormatter.format(netBalance)}', + style: AppTypography.titleLarge.copyWith( + color: netBalance >= 0 + ? AppColors.inkGreen + : AppColors.stampRed, + ), + ), + ], + ), + if (expenseByCategory.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSectionTitle('EXPENSE BREAKDOWN'), + const SizedBox(height: 16), + Row( + children: [ + SizedBox( + height: 100, + width: 100, + child: PieChart( + PieChartData( + sections: pieSections, + sectionsSpace: 2, + centerSpaceRadius: 36, + borderData: FlBorderData(show: false), + ), + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: legendWidgets, + ), + ), + ], + ), + ], + ], + ), + ); + } + Widget _buildSectionTitle(String title) { return Text( title, @@ -243,15 +475,9 @@ class ReportsScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: AppTypography.label.copyWith(fontSize: 9), - ), + Text(label, style: AppTypography.label.copyWith(fontSize: 9)), const SizedBox(height: 4), - Text( - value, - style: AppTypography.titleMedium, - ), + Text(value, style: AppTypography.titleMedium), ], ), ), @@ -278,7 +504,9 @@ class ReportsScreen extends StatelessWidget { style: OutlinedButton.styleFrom( foregroundColor: AppColors.inkBlue, side: const BorderSide(color: AppColors.inkBlue), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), textStyle: AppTypography.titleMedium, ), ), @@ -301,27 +529,40 @@ class ReportsScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 12), - Container(width: 40, height: 4, decoration: BoxDecoration(color: AppColors.divider, borderRadius: BorderRadius.circular(2))), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(2), + ), + ), const SizedBox(height: 16), const Text('Select Account', style: AppTypography.titleLarge), const SizedBox(height: 16), ListTile( title: const Text('All Accounts'), - trailing: reportsProvider.selectedAccountId == null ? const Icon(Icons.check, color: AppColors.inkBlue) : null, + trailing: reportsProvider.selectedAccountId == null + ? const Icon(Icons.check, color: AppColors.inkBlue) + : null, onTap: () { reportsProvider.setSelectedAccount(null); Navigator.pop(context); }, ), const Divider(height: 1), - ...accProvider.accounts.map((account) => ListTile( - title: Text(account.name), - trailing: reportsProvider.selectedAccountId == account.id ? const Icon(Icons.check, color: AppColors.inkBlue) : null, - onTap: () { - reportsProvider.setSelectedAccount(account.id); - Navigator.pop(context); - }, - )), + ...accProvider.accounts.map( + (account) => ListTile( + title: Text(account.name), + trailing: reportsProvider.selectedAccountId == account.id + ? const Icon(Icons.check, color: AppColors.inkBlue) + : null, + onTap: () { + reportsProvider.setSelectedAccount(account.id); + Navigator.pop(context); + }, + ), + ), const SizedBox(height: 24), ], ); @@ -329,10 +570,14 @@ class ReportsScreen extends StatelessWidget { ); } - void _generate(BuildContext context, ReportsProvider provider, String type) async { + void _generate( + BuildContext context, + ReportsProvider provider, + String type, + ) async { await provider.generate(type); if (!context.mounted) return; - + if (provider.status == ReportStatus.success) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -351,7 +596,9 @@ class ReportsScreen extends StatelessWidget { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to generate report: ${provider.errorMessage}'), + content: Text( + 'Failed to generate report: ${provider.errorMessage}', + ), backgroundColor: AppColors.stampRed, ), ); diff --git a/lib/presentation/widgets/add_edit_account_sheet.dart b/lib/presentation/widgets/add_edit_account_sheet.dart index ea9b7af..0c7adee 100644 --- a/lib/presentation/widgets/add_edit_account_sheet.dart +++ b/lib/presentation/widgets/add_edit_account_sheet.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_typography.dart'; import '../../domain/entities/account.dart'; import '../../domain/entities/enums.dart'; +import '../providers/currency_provider.dart'; /// Bottom sheet for creating or editing an account. /// @@ -23,7 +25,6 @@ class _AddEditAccountSheetState extends State { final _formKey = GlobalKey(); late final TextEditingController _nameController; late final TextEditingController _balanceController; - late final TextEditingController _currencyController; late AccountType _selectedType; bool get _isEditing => widget.existingAccount != null; @@ -36,9 +37,6 @@ class _AddEditAccountSheetState extends State { _balanceController = TextEditingController( text: account != null ? account.balance.toString() : '', ); - _currencyController = TextEditingController( - text: account?.currency ?? 'IDR', - ); _selectedType = account?.type ?? AccountType.debit; } @@ -46,20 +44,20 @@ class _AddEditAccountSheetState extends State { void dispose() { _nameController.dispose(); _balanceController.dispose(); - _currencyController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Form( + return RepaintBoundary( + child: Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, @@ -167,15 +165,22 @@ class _AddEditAccountSheetState extends State { // โ€” Balance field โ€” Row( children: [ - // Currency - SizedBox( - width: 80, - child: TextFormField( - controller: _currencyController, - decoration: const InputDecoration(labelText: 'Currency'), - textAlign: TextAlign.center, - textCapitalization: TextCapitalization.characters, - enabled: !_isEditing, + // Currency symbol (read-only, from global setting) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + decoration: BoxDecoration( + color: AppColors.paperElevated, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.divider, width: 0.5), + ), + child: Text( + context.watch().symbol.trim(), + style: AppTypography.amountMedium.copyWith( + color: AppColors.inkLight, + ), ), ), const SizedBox(width: 12), @@ -230,7 +235,7 @@ class _AddEditAccountSheetState extends State { ], ), ), - ); + )); } void _submit() { @@ -238,7 +243,7 @@ class _AddEditAccountSheetState extends State { final name = _nameController.text.trim(); final balance = int.parse(_balanceController.text.trim()); - final currency = _currencyController.text.trim().toUpperCase(); + final currency = context.read().currency; if (_isEditing) { final updated = widget.existingAccount!.copyWith( diff --git a/lib/presentation/widgets/manage_categories_sheet.dart b/lib/presentation/widgets/manage_categories_sheet.dart index 748664c..92d3454 100644 --- a/lib/presentation/widgets/manage_categories_sheet.dart +++ b/lib/presentation/widgets/manage_categories_sheet.dart @@ -34,7 +34,8 @@ class _ManageCategoriesSheetState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().loadCategories(); + final catProv = context.read(); + if (catProv.categories.isEmpty) catProv.loadCategories(); }); } @@ -48,7 +49,8 @@ class _ManageCategoriesSheetState extends State { builder: (context, scrollController) { return Consumer( builder: (context, provider, _) { - return Column( + return RepaintBoundary( + child: Column( children: [ // โ€” Handle bar โ€” Padding( @@ -113,7 +115,7 @@ class _ManageCategoriesSheetState extends State { ), ), ], - ); + )); }, ); }, diff --git a/lib/presentation/widgets/pay_bill_sheet.dart b/lib/presentation/widgets/pay_bill_sheet.dart index 30e901d..268b86c 100644 --- a/lib/presentation/widgets/pay_bill_sheet.dart +++ b/lib/presentation/widgets/pay_bill_sheet.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_typography.dart'; import '../../domain/entities/account.dart'; import '../../domain/value_objects/money.dart'; +import '../providers/currency_provider.dart'; /// Bottom sheet for one-touch credit card bill settlement. /// @@ -108,14 +110,15 @@ class _PayBillSheetState extends State { currency: widget.creditAccount.currency, ); - return Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( + return RepaintBoundary( + child: Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -318,7 +321,7 @@ class _PayBillSheetState extends State { fontSize: 24, ), decoration: InputDecoration( - prefixText: 'Rp ', + prefixText: '${context.read().symbol} ', prefixStyle: AppTypography.amountLarge.copyWith( color: AppColors.inkLight, fontSize: 24, @@ -378,6 +381,6 @@ class _PayBillSheetState extends State { ), ], ), - ); + )); } } diff --git a/lib/presentation/widgets/quick_add_transaction_sheet.dart b/lib/presentation/widgets/quick_add_transaction_sheet.dart index 69fbf53..0d22bd2 100644 --- a/lib/presentation/widgets/quick_add_transaction_sheet.dart +++ b/lib/presentation/widgets/quick_add_transaction_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_typography.dart'; @@ -7,6 +8,7 @@ import '../../core/utils/category_icon_mapper.dart'; import '../../domain/entities/account.dart'; import '../../domain/entities/category.dart'; import '../../domain/entities/enums.dart'; +import '../providers/currency_provider.dart'; /// Quick-add bottom sheet: Category โ†’ Amount โ†’ Source โ†’ Log It โœ“ /// @@ -47,6 +49,11 @@ class _QuickAddTransactionSheetState extends State { bool get _isEditing => widget.initialValues != null; + /// Resolves the currency symbol from the global CurrencyProvider. + String get _currencySymbol { + return context.read().symbol.trim(); + } + @override void initState() { super.initState(); @@ -82,14 +89,15 @@ class _QuickAddTransactionSheetState extends State { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: 24, - right: 24, - top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: SingleChildScrollView( + return RepaintBoundary( + child: Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -218,7 +226,7 @@ class _QuickAddTransactionSheetState extends State { ], ), ), - ); + )); } // โ€”โ€”โ€” Sub-widgets โ€”โ€”โ€” @@ -337,7 +345,7 @@ class _QuickAddTransactionSheetState extends State { child: Row( children: [ Text( - 'Rp', + _currencySymbol, style: AppTypography.amountMedium.copyWith( color: AppColors.inkLight, ), @@ -466,7 +474,7 @@ class _QuickAddTransactionSheetState extends State { context: context, initialDate: _dateTime, firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 1)), + lastDate: DateTime(DateTime.now().year + 5), ); if (date == null || !mounted) return; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6676643..826a60a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,18 @@ import FlutterMacOS import Foundation +import firebase_core +import firebase_crashlytics import google_sign_in_ios import share_plus +import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/mock_data.sql b/mock_data.sql new file mode 100644 index 0000000..bb724a2 --- /dev/null +++ b/mock_data.sql @@ -0,0 +1,42 @@ +-- Clear existing data +DELETE FROM transactions; +DELETE FROM accounts; + +-- Accounts +INSERT INTO accounts (id, name, type, balance, currency, is_archived, created_at) VALUES +('acc_bca_1', 'BCA Payroll', 0, 15000000, 'IDR', 0, 1772217934680), +('acc_mandiri_1', 'Mandiri Savings', 0, 8000000, 'IDR', 0, 1772217934680), +('acc_cash_1', 'Main Wallet', 1, 500000, 'IDR', 0, 1772217934680), +('acc_cash_2', 'Emergency Stash', 1, 1500000, 'IDR', 0, 1772217934680), +('acc_credit_1', 'Tokopedia Card', 2, 2500000, 'IDR', 0, 1772217934680), +('acc_credit_2', 'Traveloka PayLater', 2, 800000, 'IDR', 0, 1772217934680); + +-- Transactions +-- Categories available: 'food', 'transport', 'bills', 'shopping', 'entertainment', 'health', 'education', 'other', 'settlement' +INSERT INTO transactions (id, amount, type, category_id, account_id, to_account_id, note, is_settlement, date_time) VALUES +('txn_001', 12000000, 1, 'other', 'acc_bca_1', NULL, 'Monthly Salary', 0, 1769922000000), +('txn_002', 20000, 0, 'transport', 'acc_cash_1', NULL, 'Bus Ticket', 0, 1770008400000), +('txn_003', 650000, 0, 'shopping', 'acc_credit_1', NULL, 'Grocery at Superindo', 0, 1770094800000), +('txn_004', 35000, 0, 'food', 'acc_cash_2', NULL, 'Nasi Goreng', 0, 1770094800000), +('txn_005', 450000, 0, 'bills', 'acc_mandiri_1', NULL, 'Electricity Token', 0, 1770267600000), +('txn_006', 150000, 0, 'health', 'acc_bca_1', NULL, 'Pharmacy - Vitamins', 0, 1770354000000), +('txn_007', 350000, 0, 'education', 'acc_credit_2', NULL, 'Udemy Course', 0, 1770440400000), +('txn_008', 50000, 0, 'transport', 'acc_cash_1', NULL, 'GoRide to Office', 0, 1770526800000), +('txn_009', 2500000, 1, 'other', 'acc_bca_1', NULL, 'Freelance Project', 0, 1770613200000), +('txn_010', 300000, 0, 'bills', 'acc_mandiri_1', NULL, 'Water Bill PDAM', 0, 1770699600000), +('txn_011', 180000, 0, 'food', 'acc_credit_1', NULL, 'Sushi Tei Lunch', 0, 1770786000000), +('txn_012', 30000, 0, 'transport', 'acc_cash_2', NULL, 'Parking Fee', 0, 1770872400000), +('txn_013', 120000, 0, 'entertainment', 'acc_bca_1', NULL, 'Movie Tickets', 0, 1770958800000), +('txn_014', 500000, 0, 'other', 'acc_cash_1', NULL, 'Gift for Mom', 0, 1771045200000), +('txn_015', 750000, 0, 'shopping', 'acc_credit_2', NULL, 'New Shoes', 0, 1771131600000), +('txn_016', 500000, 2, 'other', 'acc_bca_1', 'acc_cash_1', 'ATM Withdrawal', 0, 1771218000000), +('txn_017', 220000, 0, 'food', 'acc_mandiri_1', NULL, 'Dinner at Pizza Hut', 0, 1771304400000), +('txn_018', 450000, 0, 'health', 'acc_credit_1', NULL, 'Dental Checkup', 0, 1771390800000), +('txn_019', 1500000, 0, 'entertainment', 'acc_credit_2', NULL, 'Concert Ticket', 0, 1771477200000), +('txn_020', 25000, 0, 'food', 'acc_cash_1', NULL, 'Coffee', 0, 1771563600000), +('txn_021', 100000, 0, 'transport', 'acc_bca_1', NULL, 'Toll Top Up', 0, 1771650000000), +('txn_022', 150000, 0, 'education', 'acc_mandiri_1', NULL, 'Book Purchase', 0, 1771736400000), +('txn_023', 80000, 0, 'bills', 'acc_cash_2', NULL, 'Mobile Prepard Credit', 0, 1771822800000), +('txn_024', 169000, 0, 'entertainment', 'acc_credit_1', NULL, 'Netflix Subscription', 0, 1771909200000), +('txn_025', 1000000, 1, 'other', 'acc_mandiri_1', NULL, 'Bonus', 0, 1771995600000), +('txn_026', 1500000, 2, 'settlement', 'acc_bca_1', 'acc_credit_1', 'Pay CC Bill', 1, 1772082000000); diff --git a/pubspec.lock b/pubspec.lock index 53882a7..773b99e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "93.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" analyzer: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -209,14 +225,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" - excel: - dependency: "direct main" - description: - name: excel - sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780" - url: "https://pub.dev" - source: hosted - version: "4.0.6" fake_async: dependency: transitive description: @@ -241,6 +249,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1" + url: "https://pub.dev" + source: hosted + version: "4.3.10" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb" + url: "https://pub.dev" + source: hosted + version: "3.8.10" fixnum: dependency: transitive description: @@ -249,11 +297,27 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -680,6 +744,62 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d944790..a3c1ab7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,10 +30,15 @@ dependencies: googleapis: ^14.0.0 http: ^1.2.2 + # Firebase / Crashlytics + firebase_core: ^3.12.1 + firebase_crashlytics: ^4.3.2 + # Report Generation (prep โ€” wired in Phase 2) pdf: ^3.11.2 - excel: ^4.0.6 share_plus: ^12.0.1 + fl_chart: ^1.1.1 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: @@ -41,6 +46,15 @@ dev_dependencies: flutter_lints: ^6.0.0 mockito: ^5.4.5 build_runner: ^2.4.14 + flutter_launcher_icons: ^0.13.1 + +flutter_launcher_icons: + android: true + ios: true + image_path: "1024.png" + adaptive_icon_background: "#FFFFFF" + adaptive_icon_foreground: "1024.png" + flutter: uses-material-design: true diff --git a/seed_mock_data.sh b/seed_mock_data.sh new file mode 100755 index 0000000..1fbb786 --- /dev/null +++ b/seed_mock_data.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# --- VentExpensePro Mock Data Seeding Script --- +# This script forcefully overwrites the app's SQLite database +# with a fresh set of realistic ledger mock data mapped to the current month and year. +# +# Usage: ./seed_mock_data.sh + +echo "=> Generating mock_data.sql with dynamically accurate timestamps..." +cat << 'EOF' > generate_mock.dart +import 'dart:io'; + +void main() { + final nowMs = DateTime.now().millisecondsSinceEpoch; + int year = DateTime.now().year; + int month = DateTime.now().month; + + int time(int day) => DateTime(year, month, day, 12, 0).millisecondsSinceEpoch; + + final sql = """ +-- Clear existing data +DELETE FROM transactions; +DELETE FROM accounts; + +-- Accounts +INSERT INTO accounts (id, name, type, balance, currency, is_archived, created_at) VALUES +('acc_bca_1', 'BCA Payroll', 0, 15000000, 'IDR', 0, $nowMs), +('acc_mandiri_1', 'Mandiri Savings', 0, 8000000, 'IDR', 0, $nowMs), +('acc_cash_1', 'Main Wallet', 1, 500000, 'IDR', 0, $nowMs), +('acc_cash_2', 'Emergency Stash', 1, 1500000, 'IDR', 0, $nowMs), +('acc_credit_1', 'Tokopedia Card', 2, 2500000, 'IDR', 0, $nowMs), +('acc_credit_2', 'Traveloka PayLater', 2, 800000, 'IDR', 0, $nowMs); + +-- Transactions +-- Categories available: 'food', 'transport', 'bills', 'shopping', 'entertainment', 'health', 'education', 'other', 'settlement' +INSERT INTO transactions (id, amount, type, category_id, account_id, to_account_id, note, is_settlement, date_time) VALUES +('txn_001', 12000000, 1, 'other', 'acc_bca_1', NULL, 'Monthly Salary', 0, ${time(1)}), +('txn_002', 20000, 0, 'transport', 'acc_cash_1', NULL, 'Bus Ticket', 0, ${time(2)}), +('txn_003', 650000, 0, 'shopping', 'acc_credit_1', NULL, 'Grocery at Superindo', 0, ${time(3)}), +('txn_004', 35000, 0, 'food', 'acc_cash_2', NULL, 'Nasi Goreng', 0, ${time(3)}), +('txn_005', 450000, 0, 'bills', 'acc_mandiri_1', NULL, 'Electricity Token', 0, ${time(5)}), +('txn_006', 150000, 0, 'health', 'acc_bca_1', NULL, 'Pharmacy - Vitamins', 0, ${time(6)}), +('txn_007', 350000, 0, 'education', 'acc_credit_2', NULL, 'Udemy Course', 0, ${time(7)}), +('txn_008', 50000, 0, 'transport', 'acc_cash_1', NULL, 'GoRide to Office', 0, ${time(8)}), +('txn_009', 2500000, 1, 'other', 'acc_bca_1', NULL, 'Freelance Project', 0, ${time(9)}), +('txn_010', 300000, 0, 'bills', 'acc_mandiri_1', NULL, 'Water Bill PDAM', 0, ${time(10)}), +('txn_011', 180000, 0, 'food', 'acc_credit_1', NULL, 'Sushi Tei Lunch', 0, ${time(11)}), +('txn_012', 30000, 0, 'transport', 'acc_cash_2', NULL, 'Parking Fee', 0, ${time(12)}), +('txn_013', 120000, 0, 'entertainment', 'acc_bca_1', NULL, 'Movie Tickets', 0, ${time(13)}), +('txn_014', 500000, 0, 'other', 'acc_cash_1', NULL, 'Gift for Mom', 0, ${time(14)}), +('txn_015', 750000, 0, 'shopping', 'acc_credit_2', NULL, 'New Shoes', 0, ${time(15)}), +('txn_016', 500000, 2, 'other', 'acc_bca_1', 'acc_cash_1', 'ATM Withdrawal', 0, ${time(16)}), +('txn_017', 220000, 0, 'food', 'acc_mandiri_1', NULL, 'Dinner at Pizza Hut', 0, ${time(17)}), +('txn_018', 450000, 0, 'health', 'acc_credit_1', NULL, 'Dental Checkup', 0, ${time(18)}), +('txn_019', 1500000, 0, 'entertainment', 'acc_credit_2', NULL, 'Concert Ticket', 0, ${time(19)}), +('txn_020', 25000, 0, 'food', 'acc_cash_1', NULL, 'Coffee', 0, ${time(20)}), +('txn_021', 100000, 0, 'transport', 'acc_bca_1', NULL, 'Toll Top Up', 0, ${time(21)}), +('txn_022', 150000, 0, 'education', 'acc_mandiri_1', NULL, 'Book Purchase', 0, ${time(22)}), +('txn_023', 80000, 0, 'bills', 'acc_cash_2', NULL, 'Mobile Prepard Credit', 0, ${time(23)}), +('txn_024', 169000, 0, 'entertainment', 'acc_credit_1', NULL, 'Netflix Subscription', 0, ${time(24)}), +('txn_025', 1000000, 1, 'other', 'acc_mandiri_1', NULL, 'Bonus', 0, ${time(25)}), +('txn_026', 1500000, 2, 'settlement', 'acc_bca_1', 'acc_credit_1', 'Pay CC Bill', 1, ${time(26)}); +"""; + + File('mock_data.sql').writeAsStringSync(sql); +} +EOF + +# Run Dart script to construct the exact mock SQL file for this moment in time +dart generate_mock.dart +rm generate_mock.dart + +echo "=> Force stopping VentExpensePro to clear SQL WAL memory locks..." +adb shell am force-stop com.digiventure.ventexpensepro + +echo "=> Pulling the latest local database natively from the Android emulator..." +adb shell "run-as com.digiventure.ventexpensepro cat databases/vent_expense.db > /data/local/tmp/vent_expense.db" +adb pull /data/local/tmp/vent_expense.db temp_db.sqlite + +echo "=> Injecting dynamic mock data into SQLite via CLI..." +sqlite3 temp_db.sqlite < mock_data.sql + +echo "=> Pushing populated database back to the emulator architecture..." +adb push temp_db.sqlite /data/local/tmp/vent_expense.db +adb shell "run-as com.digiventure.ventexpensepro cp /data/local/tmp/vent_expense.db databases/vent_expense.db" + +echo "=> Cleaning up temporary files..." +rm temp_db.sqlite + +echo "" +echo "โœ… Seeding Complete! You can now launch VentExpensePro natively on your emulator." +echo " (Or press 'r' / hot restart in your fluttering running console)" diff --git a/temp.db b/temp.db new file mode 100644 index 0000000..592ade9 Binary files /dev/null and b/temp.db differ diff --git a/test/core/utils/category_icon_mapper_test.dart b/test/core/utils/category_icon_mapper_test.dart new file mode 100644 index 0000000..607e539 --- /dev/null +++ b/test/core/utils/category_icon_mapper_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vent_expense_pro/core/utils/category_icon_mapper.dart'; +import 'package:vent_expense_pro/domain/entities/enums.dart'; + +void main() { + group('CategoryIconMapper', () { + group('iconFor', () { + test('should return restaurant icon for food', () { + expect( + CategoryIconMapper.iconFor('food'), Icons.restaurant_outlined); + }); + + test('should return bus icon for transport', () { + expect(CategoryIconMapper.iconFor('transport'), + Icons.directions_bus_outlined); + }); + + test('should return receipt icon for bills', () { + expect(CategoryIconMapper.iconFor('bills'), Icons.receipt_outlined); + }); + + test('should return shopping bag icon for shopping', () { + expect(CategoryIconMapper.iconFor('shopping'), + Icons.shopping_bag_outlined); + }); + + test('should return movie icon for entertainment', () { + expect(CategoryIconMapper.iconFor('entertainment'), + Icons.movie_outlined); + }); + + test('should return heart icon for health', () { + expect( + CategoryIconMapper.iconFor('health'), Icons.favorite_outlined); + }); + + test('should return school icon for education', () { + expect( + CategoryIconMapper.iconFor('education'), Icons.school_outlined); + }); + + test('should return more icon for other', () { + expect(CategoryIconMapper.iconFor('other'), Icons.more_horiz_outlined); + }); + + test('should return sync icon for settlement', () { + expect(CategoryIconMapper.iconFor('settlement'), + Icons.sync_alt_outlined); + }); + + test('should return category icon for unknown', () { + expect(CategoryIconMapper.iconFor('unknown_category'), + Icons.category_outlined); + }); + }); + + group('labelForType', () { + test('should return "Expense" for expense type', () { + expect( + CategoryIconMapper.labelForType(TransactionType.expense), + 'Expense'); + }); + + test('should return "Income" for income type', () { + expect( + CategoryIconMapper.labelForType(TransactionType.income), + 'Income'); + }); + + test('should return "Transfer" for transfer type', () { + expect( + CategoryIconMapper.labelForType(TransactionType.transfer), + 'Transfer'); + }); + }); + + group('signForType', () { + test('should return minus sign for expense', () { + expect( + CategoryIconMapper.signForType(TransactionType.expense), 'โˆ’ '); + }); + + test('should return plus sign for income', () { + expect( + CategoryIconMapper.signForType(TransactionType.income), '+ '); + }); + + test('should return empty string for transfer', () { + expect( + CategoryIconMapper.signForType(TransactionType.transfer), ''); + }); + }); + + group('colorForType', () { + test('should return a color for each transaction type', () { + expect(CategoryIconMapper.colorForType(TransactionType.expense), + isA()); + expect(CategoryIconMapper.colorForType(TransactionType.income), + isA()); + expect(CategoryIconMapper.colorForType(TransactionType.transfer), + isA()); + }); + + test('should return different colors for different types', () { + final expense = + CategoryIconMapper.colorForType(TransactionType.expense); + final income = + CategoryIconMapper.colorForType(TransactionType.income); + final transfer = + CategoryIconMapper.colorForType(TransactionType.transfer); + expect(expense, isNot(equals(income))); + expect(expense, isNot(equals(transfer))); + }); + }); + }); +} diff --git a/test/core/utils/currency_formatter_test.dart b/test/core/utils/currency_formatter_test.dart new file mode 100644 index 0000000..835958e --- /dev/null +++ b/test/core/utils/currency_formatter_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vent_expense_pro/core/utils/currency_formatter.dart'; + +void main() { + group('CurrencyFormatter', () { + group('formatCents', () { + test('should format IDR with no decimals', () { + final result = CurrencyFormatter.formatCents(1500000, currency: 'IDR'); + expect(result, contains('Rp')); + expect(result, contains('1.500.000')); + }); + + test('should format zero IDR', () { + final result = CurrencyFormatter.formatCents(0, currency: 'IDR'); + expect(result, contains('Rp')); + expect(result, contains('0')); + }); + + test('should format negative IDR', () { + final result = CurrencyFormatter.formatCents(-500000, currency: 'IDR'); + expect(result, contains('500.000')); + }); + + test('should format USD with 2 decimals', () { + final result = CurrencyFormatter.formatCents(15050, currency: 'USD'); + expect(result, contains('\$')); + expect(result, contains('150.50')); + }); + + test('should format EUR correctly', () { + final result = CurrencyFormatter.formatCents(10000, currency: 'EUR'); + expect(result, contains('100')); + }); + + test('should format GBP correctly', () { + final result = CurrencyFormatter.formatCents(25099, currency: 'GBP'); + expect(result, contains('250.99')); + }); + + test('should format JPY with no decimals', () { + final result = CurrencyFormatter.formatCents(5000, currency: 'JPY'); + expect(result, contains('5')); + }); + + test('should format KRW with no decimals', () { + final result = CurrencyFormatter.formatCents(50000, currency: 'KRW'); + expect(result, contains('50')); + }); + + test('should format SGD correctly', () { + final result = CurrencyFormatter.formatCents(1250, currency: 'SGD'); + expect(result, contains('12.50')); + }); + + test('should format MYR correctly', () { + final result = CurrencyFormatter.formatCents(7500, currency: 'MYR'); + expect(result, contains('75.00')); + }); + }); + + group('formatCentsPlain', () { + test('should format IDR without symbol', () { + final result = + CurrencyFormatter.formatCentsPlain(1500000, currency: 'IDR'); + expect(result, isNot(contains('Rp'))); + expect(result, contains('1.500.000')); + }); + + test('should format USD without symbol', () { + final result = + CurrencyFormatter.formatCentsPlain(15050, currency: 'USD'); + expect(result, isNot(contains('\$'))); + expect(result, contains('150.5')); + }); + }); + + group('symbol', () { + test('should return Rp for IDR', () { + expect(CurrencyFormatter.symbol('IDR'), contains('Rp')); + }); + + test('should return \$ for USD', () { + expect(CurrencyFormatter.symbol('USD'), contains('\$')); + }); + + test('should return ยฃ for GBP', () { + expect(CurrencyFormatter.symbol('GBP'), contains('ยฃ')); + }); + + test('should return ยฅ for JPY', () { + final sym = CurrencyFormatter.symbol('JPY'); + expect(sym.isNotEmpty, true); + }); + }); + + group('decimalDigits', () { + test('should return 0 for IDR', () { + expect(CurrencyFormatter.decimalDigits('IDR'), 0); + }); + + test('should return 0 for JPY', () { + expect(CurrencyFormatter.decimalDigits('JPY'), 0); + }); + + test('should return 0 for KRW', () { + expect(CurrencyFormatter.decimalDigits('KRW'), 0); + }); + + test('should return 2 for USD', () { + expect(CurrencyFormatter.decimalDigits('USD'), 2); + }); + + test('should return 2 for EUR', () { + expect(CurrencyFormatter.decimalDigits('EUR'), 2); + }); + + test('should return 2 for unknown currency', () { + expect(CurrencyFormatter.decimalDigits('XYZ'), 2); + }); + }); + }); +} diff --git a/test/core/utils/date_formatter_test.dart b/test/core/utils/date_formatter_test.dart new file mode 100644 index 0000000..c531aa9 --- /dev/null +++ b/test/core/utils/date_formatter_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vent_expense_pro/core/utils/date_formatter.dart'; + +void main() { + group('DateFormatter', () { + test('full should format as "d MMMM yyyy"', () { + final date = DateTime(2026, 2, 21); + expect(DateFormatter.full(date), '21 February 2026'); + }); + + test('short should format as "d MMM yyyy"', () { + final date = DateTime(2026, 2, 21); + expect(DateFormatter.short(date), '21 Feb 2026'); + }); + + test('dayMonth should format as "d MMM"', () { + final date = DateTime(2026, 12, 25); + expect(DateFormatter.dayMonth(date), '25 Dec'); + }); + + test('time should format as "HH:mm"', () { + final date = DateTime(2026, 2, 21, 14, 30); + expect(DateFormatter.time(date), '14:30'); + }); + + test('time should pad single digit hours and minutes', () { + final date = DateTime(2026, 1, 1, 8, 5); + expect(DateFormatter.time(date), '08:05'); + }); + + group('relative', () { + test('should return "Today" for today', () { + final now = DateTime.now(); + expect(DateFormatter.relative(now), 'Today'); + }); + + test('should return "Yesterday" for yesterday', () { + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + expect(DateFormatter.relative(yesterday), 'Yesterday'); + }); + + test('should return day name for dates within a week', () { + final threeDaysAgo = DateTime.now().subtract(const Duration(days: 3)); + final result = DateFormatter.relative(threeDaysAgo); + // Should be a day name (Monday, Tuesday, etc.), not a date + expect(result, isNot(contains('2026'))); + expect(result.length, greaterThan(3)); + }); + + test('should return short date for dates older than a week', () { + final oldDate = DateTime.now().subtract(const Duration(days: 10)); + final result = DateFormatter.relative(oldDate); + // Should contain a year + expect(result, contains('202')); + }); + }); + + group('receiptHeader', () { + test('should return "Today, d MMM" for today', () { + final now = DateTime.now(); + final result = DateFormatter.receiptHeader(now); + expect(result, startsWith('Today')); + expect(result, contains(',')); + }); + + test('should return "Yesterday, d MMM" for yesterday', () { + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + final result = DateFormatter.receiptHeader(yesterday); + expect(result, startsWith('Yesterday')); + expect(result, contains(',')); + }); + + test('should return "DayName, d MMM" for older dates', () { + final old = DateTime.now().subtract(const Duration(days: 5)); + final result = DateFormatter.receiptHeader(old); + expect(result, contains(',')); + // Should NOT start with Today or Yesterday + expect(result, isNot(startsWith('Today'))); + expect(result, isNot(startsWith('Yesterday'))); + }); + }); + }); +} diff --git a/test/data/models/account_model_test.dart b/test/data/models/account_model_test.dart new file mode 100644 index 0000000..0a116af --- /dev/null +++ b/test/data/models/account_model_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vent_expense_pro/data/models/account_model.dart'; +import 'package:vent_expense_pro/domain/entities/account.dart'; +import 'package:vent_expense_pro/domain/entities/enums.dart'; + +void main() { + group('AccountModel', () { + final now = DateTime(2026, 2, 21, 12, 0); + + group('fromMap', () { + test('should create an AccountModel from a valid SQLite map', () { + final map = { + 'id': 'acc-1', + 'name': 'BCA Payroll', + 'type': AccountType.debit.index, + 'balance': 15000000, + 'currency': 'IDR', + 'is_archived': 0, + 'created_at': now.millisecondsSinceEpoch, + }; + + final model = AccountModel.fromMap(map); + + expect(model.id, 'acc-1'); + expect(model.name, 'BCA Payroll'); + expect(model.type, AccountType.debit); + expect(model.balance, 15000000); + expect(model.currency, 'IDR'); + expect(model.isArchived, false); + expect(model.createdAt, now); + }); + + test('should handle archived accounts', () { + final map = { + 'id': 'acc-2', + 'name': 'Old Card', + 'type': AccountType.credit.index, + 'balance': 0, + 'currency': 'USD', + 'is_archived': 1, + 'created_at': now.millisecondsSinceEpoch, + }; + + final model = AccountModel.fromMap(map); + expect(model.isArchived, true); + expect(model.type, AccountType.credit); + expect(model.currency, 'USD'); + }); + + test('should default currency to IDR when null', () { + final map = { + 'id': 'acc-3', + 'name': 'Cash', + 'type': AccountType.cash.index, + 'balance': 500000, + 'currency': null, + 'is_archived': 0, + 'created_at': now.millisecondsSinceEpoch, + }; + + final model = AccountModel.fromMap(map); + expect(model.currency, 'IDR'); + }); + }); + + group('fromEntity', () { + test('should create an AccountModel from a domain Account', () { + final account = Account( + id: 'acc-1', + name: 'Mandiri', + type: AccountType.debit, + balance: 8000000, + currency: 'IDR', + createdAt: now, + ); + + final model = AccountModel.fromEntity(account); + + expect(model.id, account.id); + expect(model.name, account.name); + expect(model.type, account.type); + expect(model.balance, account.balance); + expect(model.currency, account.currency); + expect(model.isArchived, account.isArchived); + expect(model.createdAt, account.createdAt); + }); + }); + + group('toMap', () { + test('should convert to a valid SQLite map', () { + final model = AccountModel( + id: 'acc-1', + name: 'BCA', + type: AccountType.debit, + balance: 1000000, + currency: 'IDR', + createdAt: now, + ); + + final map = model.toMap(); + + expect(map['id'], 'acc-1'); + expect(map['name'], 'BCA'); + expect(map['type'], AccountType.debit.index); + expect(map['balance'], 1000000); + expect(map['currency'], 'IDR'); + expect(map['is_archived'], 0); + expect(map['created_at'], now.millisecondsSinceEpoch); + }); + + test('should set is_archived to 1 for archived accounts', () { + final model = AccountModel( + id: 'acc-old', + name: 'Closed', + type: AccountType.cash, + balance: 0, + isArchived: true, + createdAt: now, + ); + + final map = model.toMap(); + expect(map['is_archived'], 1); + }); + }); + + group('round-trip', () { + test('fromMap โ†’ toMap should produce equivalent data', () { + final original = { + 'id': 'acc-rt', + 'name': 'Round Trip', + 'type': AccountType.debit.index, + 'balance': 999, + 'currency': 'USD', + 'is_archived': 0, + 'created_at': now.millisecondsSinceEpoch, + }; + + final model = AccountModel.fromMap(original); + final result = model.toMap(); + + expect(result['id'], original['id']); + expect(result['name'], original['name']); + expect(result['type'], original['type']); + expect(result['balance'], original['balance']); + expect(result['currency'], original['currency']); + expect(result['is_archived'], original['is_archived']); + expect(result['created_at'], original['created_at']); + }); + }); + }); +} diff --git a/test/data/models/category_model_test.dart b/test/data/models/category_model_test.dart new file mode 100644 index 0000000..27ea669 --- /dev/null +++ b/test/data/models/category_model_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vent_expense_pro/data/models/category_model.dart'; +import 'package:vent_expense_pro/domain/entities/category.dart'; + +void main() { + group('CategoryModel', () { + group('fromMap', () { + test('should create a default CategoryModel from map', () { + final map = { + 'id': 'food', + 'name': 'Food', + 'icon': 'food', + 'is_custom': 0, + }; + + final model = CategoryModel.fromMap(map); + + expect(model.id, 'food'); + expect(model.name, 'Food'); + expect(model.icon, 'food'); + expect(model.isCustom, false); + }); + + test('should create a custom CategoryModel from map', () { + final map = { + 'id': 'custom-1', + 'name': 'Pets', + 'icon': 'pets', + 'is_custom': 1, + }; + + final model = CategoryModel.fromMap(map); + expect(model.isCustom, true); + }); + }); + + group('fromEntity', () { + test('should create a CategoryModel from a domain Category', () { + const category = Category( + id: 'bills', + name: 'Bills', + icon: 'bills', + ); + + final model = CategoryModel.fromEntity(category); + + expect(model.id, category.id); + expect(model.name, category.name); + expect(model.icon, category.icon); + expect(model.isCustom, category.isCustom); + }); + }); + + group('toMap', () { + test('should convert default category to SQLite map', () { + const model = CategoryModel( + id: 'food', + name: 'Food', + icon: 'food', + ); + + final map = model.toMap(); + + expect(map['id'], 'food'); + expect(map['name'], 'Food'); + expect(map['icon'], 'food'); + expect(map['is_custom'], 0); + }); + + test('should convert custom category to SQLite map', () { + const model = CategoryModel( + id: 'custom-1', + name: 'Gym', + icon: 'gym', + isCustom: true, + ); + + final map = model.toMap(); + expect(map['is_custom'], 1); + }); + }); + + group('round-trip', () { + test('fromMap โ†’ toMap should produce equivalent data', () { + final original = { + 'id': 'transport', + 'name': 'Transport', + 'icon': 'transport', + 'is_custom': 0, + }; + + final model = CategoryModel.fromMap(original); + final result = model.toMap(); + + expect(result, equals(original)); + }); + }); + }); +} diff --git a/test/data/models/transaction_model_test.dart b/test/data/models/transaction_model_test.dart new file mode 100644 index 0000000..c97cf1d --- /dev/null +++ b/test/data/models/transaction_model_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vent_expense_pro/data/models/transaction_model.dart'; +import 'package:vent_expense_pro/domain/entities/enums.dart'; +import 'package:vent_expense_pro/domain/entities/transaction.dart'; + +void main() { + group('TransactionModel', () { + final now = DateTime(2026, 2, 21, 14, 30); + + group('fromMap', () { + test('should create an expense TransactionModel from map', () { + final map = { + 'id': 'txn-1', + 'amount': 50000, + 'type': TransactionType.expense.index, + 'category_id': 'food', + 'account_id': 'acc-1', + 'to_account_id': null, + 'note': 'Lunch', + 'is_settlement': 0, + 'date_time': now.millisecondsSinceEpoch, + }; + + final model = TransactionModel.fromMap(map); + + expect(model.id, 'txn-1'); + expect(model.amount, 50000); + expect(model.type, TransactionType.expense); + expect(model.categoryId, 'food'); + expect(model.accountId, 'acc-1'); + expect(model.toAccountId, isNull); + expect(model.note, 'Lunch'); + expect(model.isSettlement, false); + expect(model.dateTime, now); + }); + + test('should create a settlement transfer from map', () { + final map = { + 'id': 'txn-2', + 'amount': 1500000, + 'type': TransactionType.transfer.index, + 'category_id': 'settlement', + 'account_id': 'acc-1', + 'to_account_id': 'acc-2', + 'note': 'Pay CC Bill', + 'is_settlement': 1, + 'date_time': now.millisecondsSinceEpoch, + }; + + final model = TransactionModel.fromMap(map); + + expect(model.type, TransactionType.transfer); + expect(model.toAccountId, 'acc-2'); + expect(model.isSettlement, true); + }); + + test('should handle null note and toAccountId', () { + final map = { + 'id': 'txn-3', + 'amount': 100000, + 'type': TransactionType.income.index, + 'category_id': 'other', + 'account_id': 'acc-1', + 'to_account_id': null, + 'note': null, + 'is_settlement': 0, + 'date_time': now.millisecondsSinceEpoch, + }; + + final model = TransactionModel.fromMap(map); + expect(model.toAccountId, isNull); + expect(model.note, isNull); + }); + }); + + group('fromEntity', () { + test('should create a TransactionModel from domain Transaction', () { + final txn = Transaction( + id: 'txn-1', + amount: 50000, + type: TransactionType.expense, + categoryId: 'food', + accountId: 'acc-1', + note: 'Dinner', + dateTime: now, + ); + + final model = TransactionModel.fromEntity(txn); + + expect(model.id, txn.id); + expect(model.amount, txn.amount); + expect(model.type, txn.type); + expect(model.categoryId, txn.categoryId); + expect(model.accountId, txn.accountId); + expect(model.note, txn.note); + expect(model.isSettlement, txn.isSettlement); + expect(model.dateTime, txn.dateTime); + }); + }); + + group('toMap', () { + test('should convert to a valid SQLite map', () { + final model = TransactionModel( + id: 'txn-1', + amount: 75000, + type: TransactionType.expense, + categoryId: 'shopping', + accountId: 'acc-1', + note: 'New shoes', + dateTime: now, + ); + + final map = model.toMap(); + + expect(map['id'], 'txn-1'); + expect(map['amount'], 75000); + expect(map['type'], TransactionType.expense.index); + expect(map['category_id'], 'shopping'); + expect(map['account_id'], 'acc-1'); + expect(map['to_account_id'], isNull); + expect(map['note'], 'New shoes'); + expect(map['is_settlement'], 0); + expect(map['date_time'], now.millisecondsSinceEpoch); + }); + + test('should set is_settlement to 1 for settlement transactions', () { + final model = TransactionModel( + id: 'txn-s', + amount: 500000, + type: TransactionType.transfer, + categoryId: 'settlement', + accountId: 'acc-1', + toAccountId: 'acc-2', + isSettlement: true, + dateTime: now, + ); + + final map = model.toMap(); + expect(map['is_settlement'], 1); + expect(map['to_account_id'], 'acc-2'); + }); + }); + + group('round-trip', () { + test('fromMap โ†’ toMap should produce equivalent data', () { + final original = { + 'id': 'txn-rt', + 'amount': 250000, + 'type': TransactionType.income.index, + 'category_id': 'other', + 'account_id': 'acc-1', + 'to_account_id': null, + 'note': 'Bonus', + 'is_settlement': 0, + 'date_time': now.millisecondsSinceEpoch, + }; + + final model = TransactionModel.fromMap(original); + final result = model.toMap(); + + expect(result['id'], original['id']); + expect(result['amount'], original['amount']); + expect(result['type'], original['type']); + expect(result['category_id'], original['category_id']); + expect(result['account_id'], original['account_id']); + expect(result['to_account_id'], original['to_account_id']); + expect(result['note'], original['note']); + expect(result['is_settlement'], original['is_settlement']); + expect(result['date_time'], original['date_time']); + }); + }); + }); +} diff --git a/test/domain/usecases/generate_report_test.dart b/test/domain/usecases/generate_report_test.dart index 67c9e79..9d87482 100644 --- a/test/domain/usecases/generate_report_test.dart +++ b/test/domain/usecases/generate_report_test.dart @@ -10,16 +10,6 @@ import 'package:vent_expense_pro/domain/repositories/transaction_repository.dart import 'package:vent_expense_pro/domain/usecases/generate_report.dart'; class FakeReportRepository implements ReportRepository { - @override - Future generateExcel({ - required List transactions, - required List accounts, - required List categories, - String? accountId, - DateTime? startDate, - DateTime? endDate, - }) async => 'excel_path'; - @override Future generatePdf({ required List transactions, @@ -137,9 +127,4 @@ void main() { expect(path, 'pdf_path'); }); - - test('should return excel path', () async { - final path = await generateReport(type: 'excel'); - expect(path, 'excel_path'); - }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c3384ec..c3d3b6a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 01d3836..c04ddae 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_core share_plus url_launcher_windows )