├── assets ├── icon.png └── fonts │ └── NotoSans-Regular.ttf ├── screenshots ├── Home.jpg ├── Rename.jpg ├── Settings.jpg ├── advanced.jpg ├── Analytics1.jpg ├── analytics2.jpg └── analytics3.jpg ├── analysis_3.txt ├── analysis_4.txt ├── android ├── app │ ├── src │ │ ├── main │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── values │ │ │ │ │ ├── colors.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── drawable-hdpi │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-mdpi │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-xhdpi │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-xxhdpi │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── drawable-xxxhdpi │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── xml │ │ │ │ │ └── file_paths.xml │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ └── ic_launcher.xml │ │ │ │ ├── drawable │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── drawable-v21 │ │ │ │ │ └── launch_background.xml │ │ │ │ └── values-night │ │ │ │ │ └── styles.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── dark │ │ │ │ │ └── pennywise │ │ │ │ │ └── MainActivity.kt │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ ├── proguard-rules.pro │ └── build.gradle.kts ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── build.gradle.kts └── settings.gradle.kts ├── lib ├── repositories │ ├── transaction_repository.dart │ ├── firestore_transaction_repository.dart │ └── hive_transaction_repository.dart ├── models │ ├── goal.dart │ ├── loan.dart │ ├── budget.dart │ ├── budget.g.dart │ ├── goal.g.dart │ ├── category.dart │ ├── account.g.dart │ ├── category.g.dart │ ├── account.dart │ ├── transaction.g.dart │ ├── loan.g.dart │ └── transaction.dart ├── services │ ├── biometric_service.dart │ ├── auth_service.dart │ └── sms_service.dart ├── main.dart ├── utils │ └── app_theme.dart ├── widgets │ ├── custom_bottom_nav_bar.dart │ ├── theme_toggle.dart │ ├── theme_reveal.dart │ ├── analytics_chart.dart │ ├── category_search_sheet.dart │ ├── animated_digit_text.dart │ ├── spending_chart.dart │ ├── skeleton_loading.dart │ └── profile_dialog.dart └── screens │ ├── lock_screen.dart │ ├── analytics_screen.dart │ ├── advance_screen.dart │ ├── design_playground_screen.dart │ └── sms_permission_screen.dart ├── analysis_5.txt ├── .gitignore ├── analysis_2.txt ├── analysis_options.yaml ├── analysis_6.txt ├── .metadata ├── analysis.txt ├── README.md ├── pubspec.yaml ├── project_analysis.txt └── final_analysis.txt /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/assets/icon.png -------------------------------------------------------------------------------- /screenshots/Home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/Home.jpg -------------------------------------------------------------------------------- /screenshots/Rename.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/Rename.jpg -------------------------------------------------------------------------------- /screenshots/Settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/Settings.jpg -------------------------------------------------------------------------------- /screenshots/advanced.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/advanced.jpg -------------------------------------------------------------------------------- /screenshots/Analytics1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/Analytics1.jpg -------------------------------------------------------------------------------- /screenshots/analytics2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/analytics2.jpg -------------------------------------------------------------------------------- /screenshots/analytics3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/screenshots/analytics3.jpg -------------------------------------------------------------------------------- /analysis_3.txt: -------------------------------------------------------------------------------- 1 | Analyzing 2 items... 2 | No issues found! (ran in 4.4s) 3 | -------------------------------------------------------------------------------- /analysis_4.txt: -------------------------------------------------------------------------------- 1 | Analyzing all_transactions_screen.dart... 2 | No issues found! (ran in 3.8s) 3 | -------------------------------------------------------------------------------- /assets/fonts/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/assets/fonts/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #00000000 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bhanu7773-dev/PennyWise/HEAD/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | .cxx/ 9 | 10 | # Remember to never publicly share your keystore. 11 | # See https://flutter.dev/to/reference-keystore 12 | key.properties 13 | **/*.keystore 14 | **/*.jks 15 | -------------------------------------------------------------------------------- /lib/repositories/transaction_repository.dart: -------------------------------------------------------------------------------- 1 | import '../models/transaction.dart'; 2 | 3 | abstract class TransactionRepository { 4 | Future> getTransactions(); 5 | Future addTransaction(Transaction transaction); 6 | Future deleteTransaction(String id); 7 | Future updateTransaction(Transaction transaction); 8 | Future clear(); 9 | } 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /analysis_5.txt: -------------------------------------------------------------------------------- 1 | Analyzing transaction_list.dart... 2 | 3 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:245:9 • avoid_print 4 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:246:9 • avoid_print 5 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:247:9 • avoid_print 6 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:248:9 • avoid_print 7 | 8 | 4 issues found. (ran in 4.8s) 9 | -------------------------------------------------------------------------------- /android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.google.gms.google-services") version "4.4.4" apply false 3 | id("com.google.firebase.crashlytics") version "3.0.2" apply false 4 | } 5 | 6 | allprojects { 7 | repositories { 8 | google() 9 | mavenCentral() 10 | } 11 | } 12 | 13 | val newBuildDir: Directory = 14 | rootProject.layout.buildDirectory 15 | .dir("../../build") 16 | .get() 17 | rootProject.layout.buildDirectory.value(newBuildDir) 18 | 19 | subprojects { 20 | val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 21 | project.layout.buildDirectory.value(newSubprojectBuildDir) 22 | } 23 | subprojects { 24 | project.evaluationDependsOn(":app") 25 | } 26 | 27 | tasks.register("clean") { 28 | delete(rootProject.layout.buildDirectory) 29 | } 30 | -------------------------------------------------------------------------------- /android/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | val flutterSdkPath = 3 | run { 4 | val properties = java.util.Properties() 5 | file("local.properties").inputStream().use { properties.load(it) } 6 | val flutterSdkPath = properties.getProperty("flutter.sdk") 7 | require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } 8 | flutterSdkPath 9 | } 10 | 11 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | gradlePluginPortal() 17 | } 18 | } 19 | 20 | plugins { 21 | id("dev.flutter.flutter-plugin-loader") version "1.0.0" 22 | id("com.android.application") version "8.11.1" apply false 23 | id("org.jetbrains.kotlin.android") version "2.2.20" apply false 24 | } 25 | 26 | include(":app") 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .build/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | .swiftpm/ 13 | migrate_working_dir/ 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .flutter-plugins-dependencies 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | /coverage/ 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Android Studio will place build artifacts here 43 | /android/app/debug 44 | /android/app/profile 45 | /android/app/release 46 | 47 | # Firebase Config 48 | google-services.json 49 | GoogleService-Info.plist 50 | 51 | # Signing 52 | android/key.properties 53 | *.jks 54 | *.keystore 55 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/models/goal.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hive/hive.dart'; 3 | 4 | part 'goal.g.dart'; 5 | 6 | @HiveType(typeId: 5) 7 | class Goal extends HiveObject { 8 | @HiveField(0) 9 | final String id; 10 | 11 | @HiveField(1) 12 | final String title; 13 | 14 | @HiveField(2) 15 | final double targetAmount; 16 | 17 | @HiveField(3) 18 | double savedAmount; 19 | 20 | @HiveField(4) 21 | final DateTime? deadline; 22 | 23 | @HiveField(5) 24 | final int iconCode; 25 | 26 | @HiveField(6) 27 | final int colorValue; 28 | 29 | Goal({ 30 | required this.id, 31 | required this.title, 32 | required this.targetAmount, 33 | this.savedAmount = 0.0, 34 | this.deadline, 35 | required this.iconCode, 36 | required this.colorValue, 37 | }); 38 | 39 | IconData get icon => IconData(iconCode, fontFamily: 'MaterialIcons'); 40 | Color get color => Color(colorValue); 41 | double get progress => 42 | targetAmount > 0 ? (savedAmount / targetAmount).clamp(0.0, 1.0) : 0.0; 43 | bool get isCompleted => savedAmount >= targetAmount; 44 | } 45 | -------------------------------------------------------------------------------- /lib/models/loan.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | part 'loan.g.dart'; 4 | 5 | @HiveType(typeId: 3) 6 | enum LoanType { 7 | @HiveField(0) 8 | given, // Money I gave to someone (Lent) 9 | @HiveField(1) 10 | taken, // Money I took from someone (Borrowed) 11 | } 12 | 13 | @HiveType(typeId: 4) 14 | class Loan extends HiveObject { 15 | @HiveField(0) 16 | final String id; 17 | 18 | @HiveField(1) 19 | final String title; // Person name or purpose 20 | 21 | @HiveField(2) 22 | final double totalAmount; 23 | 24 | @HiveField(3) 25 | double paidAmount; 26 | 27 | @HiveField(4) 28 | final LoanType type; 29 | 30 | @HiveField(5) 31 | final DateTime startDate; 32 | 33 | @HiveField(6) 34 | final DateTime? dueDate; 35 | 36 | @HiveField(7) 37 | final String? notes; 38 | 39 | Loan({ 40 | required this.id, 41 | required this.title, 42 | required this.totalAmount, 43 | this.paidAmount = 0.0, 44 | required this.type, 45 | required this.startDate, 46 | this.dueDate, 47 | this.notes, 48 | }); 49 | 50 | double get remainingAmount => totalAmount - paidAmount; 51 | double get progress => 52 | totalAmount > 0 ? (paidAmount / totalAmount).clamp(0.0, 1.0) : 0.0; 53 | bool get isCompleted => paidAmount >= totalAmount; 54 | } 55 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Flutter wrapper 2 | -keep class io.flutter.app.** { *; } 3 | -keep class io.flutter.plugin.** { *; } 4 | -keep class io.flutter.util.** { *; } 5 | -keep class io.flutter.view.** { *; } 6 | -keep class io.flutter.** { *; } 7 | -keep class io.flutter.plugins.** { *; } 8 | 9 | # Firebase 10 | -keep class com.google.firebase.** { *; } 11 | -keep class com.google.android.gms.** { *; } 12 | 13 | # Keep Hive models 14 | -keep class com.dark.pennywise.** { *; } 15 | 16 | # Play Core library (for deferred components / split APKs) 17 | -dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication 18 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallException 19 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallManager 20 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory 21 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder 22 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest 23 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState 24 | -dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener 25 | -dontwarn com.google.android.play.core.tasks.OnFailureListener 26 | -dontwarn com.google.android.play.core.tasks.OnSuccessListener 27 | -dontwarn com.google.android.play.core.tasks.Task 28 | -------------------------------------------------------------------------------- /lib/models/budget.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | part 'budget.g.dart'; 4 | 5 | @HiveType(typeId: 1) 6 | class Budget extends HiveObject { 7 | @HiveField(0) 8 | double monthlyLimit; 9 | 10 | @HiveField(1) 11 | int month; // 1-12 12 | 13 | @HiveField(2) 14 | int year; 15 | 16 | @HiveField(3) 17 | String accountId; 18 | 19 | @HiveField(4) 20 | Map categoryLimits; 21 | 22 | Budget({ 23 | required this.monthlyLimit, 24 | required this.month, 25 | required this.year, 26 | this.accountId = 'default', 27 | Map? categoryLimits, 28 | }) : categoryLimits = categoryLimits ?? {}; 29 | 30 | Map toJson() { 31 | return { 32 | 'monthlyLimit': monthlyLimit, 33 | 'month': month, 34 | 'year': year, 35 | 'accountId': accountId, 36 | 'categoryLimits': categoryLimits, 37 | }; 38 | } 39 | 40 | factory Budget.fromJson(Map json) { 41 | return Budget( 42 | monthlyLimit: json['monthlyLimit'], 43 | month: json['month'], 44 | year: json['year'], 45 | accountId: json['accountId'] ?? 'default', 46 | categoryLimits: 47 | (json['categoryLimits'] as Map?)?.map( 48 | (k, v) => MapEntry(k, (v as num).toDouble()), 49 | ) ?? 50 | {}, 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /analysis_2.txt: -------------------------------------------------------------------------------- 1 | Analyzing transaction_detail_screen.dart... 2 | 3 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/transaction_detail_screen.dart:469:29 • deprecated_member_use 4 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/transaction_detail_screen.dart:740:33 • deprecated_member_use 5 | info • Don't use 'BuildContext's across async gaps • lib/screens/transaction_detail_screen.dart:1628:9 • use_build_context_synchronously 6 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1968:30 • use_build_context_synchronously 7 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1985:7 • use_build_context_synchronously 8 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2490:26 • use_build_context_synchronously 9 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2491:26 • use_build_context_synchronously 10 | 11 | 7 issues found. (ran in 4.4s) 12 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /lib/repositories/firestore_transaction_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart' hide Transaction; 2 | import '../models/transaction.dart'; 3 | import 'transaction_repository.dart'; 4 | 5 | class FirestoreTransactionRepository implements TransactionRepository { 6 | final String userId; 7 | final FirebaseFirestore _firestore = FirebaseFirestore.instance; 8 | 9 | FirestoreTransactionRepository(this.userId); 10 | 11 | CollectionReference> get _collection => 12 | _firestore.collection('users').doc(userId).collection('transactions'); 13 | 14 | @override 15 | Future> getTransactions() async { 16 | final snapshot = await _collection.get(); 17 | return snapshot.docs 18 | .map((doc) => Transaction.fromJson(doc.data())) 19 | .toList(); 20 | } 21 | 22 | @override 23 | Future addTransaction(Transaction transaction) async { 24 | await _collection.doc(transaction.id).set(transaction.toJson()); 25 | } 26 | 27 | @override 28 | Future deleteTransaction(String id) async { 29 | await _collection.doc(id).delete(); 30 | } 31 | 32 | @override 33 | Future updateTransaction(Transaction transaction) async { 34 | await _collection.doc(transaction.id).update(transaction.toJson()); 35 | } 36 | 37 | @override 38 | Future clear() async { 39 | final snapshot = await _collection.get(); 40 | for (final doc in snapshot.docs) { 41 | await doc.reference.delete(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /analysis_6.txt: -------------------------------------------------------------------------------- 1 | Analyzing transaction_detail_screen.dart... 2 | 3 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/transaction_detail_screen.dart:469:29 • deprecated_member_use 4 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/transaction_detail_screen.dart:740:33 • deprecated_member_use 5 | info • Don't use 'BuildContext's across async gaps • lib/screens/transaction_detail_screen.dart:1647:9 • use_build_context_synchronously 6 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1987:30 • use_build_context_synchronously 7 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2004:36 • use_build_context_synchronously 8 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2008:7 • use_build_context_synchronously 9 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2523:26 • use_build_context_synchronously 10 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2524:26 • use_build_context_synchronously 11 | 12 | 8 issues found. (ran in 4.5s) 13 | -------------------------------------------------------------------------------- /lib/models/budget.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'budget.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class BudgetAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 1; 12 | 13 | @override 14 | Budget read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return Budget( 20 | monthlyLimit: fields[0] as double, 21 | month: fields[1] as int, 22 | year: fields[2] as int, 23 | accountId: fields[3] as String, 24 | categoryLimits: (fields[4] as Map?)?.cast(), 25 | ); 26 | } 27 | 28 | @override 29 | void write(BinaryWriter writer, Budget obj) { 30 | writer 31 | ..writeByte(5) 32 | ..writeByte(0) 33 | ..write(obj.monthlyLimit) 34 | ..writeByte(1) 35 | ..write(obj.month) 36 | ..writeByte(2) 37 | ..write(obj.year) 38 | ..writeByte(3) 39 | ..write(obj.accountId) 40 | ..writeByte(4) 41 | ..write(obj.categoryLimits); 42 | } 43 | 44 | @override 45 | int get hashCode => typeId.hashCode; 46 | 47 | @override 48 | bool operator ==(Object other) => 49 | identical(this, other) || 50 | other is BudgetAdapter && 51 | runtimeType == other.runtimeType && 52 | typeId == other.typeId; 53 | } 54 | -------------------------------------------------------------------------------- /lib/models/goal.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'goal.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class GoalAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 5; 12 | 13 | @override 14 | Goal read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return Goal( 20 | id: fields[0] as String, 21 | title: fields[1] as String, 22 | targetAmount: fields[2] as double, 23 | savedAmount: fields[3] as double, 24 | deadline: fields[4] as DateTime?, 25 | iconCode: fields[5] as int, 26 | colorValue: fields[6] as int, 27 | ); 28 | } 29 | 30 | @override 31 | void write(BinaryWriter writer, Goal obj) { 32 | writer 33 | ..writeByte(7) 34 | ..writeByte(0) 35 | ..write(obj.id) 36 | ..writeByte(1) 37 | ..write(obj.title) 38 | ..writeByte(2) 39 | ..write(obj.targetAmount) 40 | ..writeByte(3) 41 | ..write(obj.savedAmount) 42 | ..writeByte(4) 43 | ..write(obj.deadline) 44 | ..writeByte(5) 45 | ..write(obj.iconCode) 46 | ..writeByte(6) 47 | ..write(obj.colorValue); 48 | } 49 | 50 | @override 51 | int get hashCode => typeId.hashCode; 52 | 53 | @override 54 | bool operator ==(Object other) => 55 | identical(this, other) || 56 | other is GoalAdapter && 57 | runtimeType == other.runtimeType && 58 | typeId == other.typeId; 59 | } 60 | -------------------------------------------------------------------------------- /lib/models/category.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hive/hive.dart'; 3 | 4 | part 'category.g.dart'; 5 | 6 | @HiveType(typeId: 2) 7 | class Category extends HiveObject { 8 | @HiveField(0) 9 | final String id; 10 | 11 | @HiveField(1) 12 | final String name; 13 | 14 | @HiveField(2) 15 | final int iconCode; 16 | 17 | @HiveField(3) 18 | final int colorValue; 19 | 20 | @HiveField(4) 21 | final bool isCustom; 22 | 23 | @HiveField(5) 24 | final String? parentId; // ID of parent category (null if top-level) 25 | 26 | @HiveField(6) 27 | final List subcategoryIds; // IDs of subcategories 28 | 29 | Category({ 30 | required this.id, 31 | required this.name, 32 | required this.iconCode, 33 | required this.colorValue, 34 | this.isCustom = true, 35 | this.parentId, 36 | List? subcategoryIds, 37 | }) : subcategoryIds = subcategoryIds ?? []; 38 | 39 | IconData get icon => IconData(iconCode, fontFamily: 'MaterialIcons'); 40 | Color get color => Color(colorValue); 41 | 42 | bool get isSubcategory => parentId != null; 43 | bool get hasSubcategories => subcategoryIds.isNotEmpty; 44 | 45 | Category copyWith({ 46 | String? id, 47 | String? name, 48 | int? iconCode, 49 | int? colorValue, 50 | bool? isCustom, 51 | String? parentId, 52 | List? subcategoryIds, 53 | }) { 54 | return Category( 55 | id: id ?? this.id, 56 | name: name ?? this.name, 57 | iconCode: iconCode ?? this.iconCode, 58 | colorValue: colorValue ?? this.colorValue, 59 | isCustom: isCustom ?? this.isCustom, 60 | parentId: parentId ?? this.parentId, 61 | subcategoryIds: subcategoryIds ?? this.subcategoryIds, 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/models/account.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'account.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class AccountAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 6; 12 | 13 | @override 14 | Account read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return Account( 20 | id: fields[0] as String, 21 | name: fields[1] as String, 22 | colorValue: fields[2] as int, 23 | iconCode: fields[3] as int, 24 | createdAt: fields[4] as DateTime?, 25 | isDefault: fields[5] as bool, 26 | showSmsTransactions: fields[6] as bool, 27 | ); 28 | } 29 | 30 | @override 31 | void write(BinaryWriter writer, Account obj) { 32 | writer 33 | ..writeByte(7) 34 | ..writeByte(0) 35 | ..write(obj.id) 36 | ..writeByte(1) 37 | ..write(obj.name) 38 | ..writeByte(2) 39 | ..write(obj.colorValue) 40 | ..writeByte(3) 41 | ..write(obj.iconCode) 42 | ..writeByte(4) 43 | ..write(obj.createdAt) 44 | ..writeByte(5) 45 | ..write(obj.isDefault) 46 | ..writeByte(6) 47 | ..write(obj.showSmsTransactions); 48 | } 49 | 50 | @override 51 | int get hashCode => typeId.hashCode; 52 | 53 | @override 54 | bool operator ==(Object other) => 55 | identical(this, other) || 56 | other is AccountAdapter && 57 | runtimeType == other.runtimeType && 58 | typeId == other.typeId; 59 | } 60 | -------------------------------------------------------------------------------- /lib/models/category.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'category.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class CategoryAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 2; 12 | 13 | @override 14 | Category read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return Category( 20 | id: fields[0] as String, 21 | name: fields[1] as String, 22 | iconCode: fields[2] as int, 23 | colorValue: fields[3] as int, 24 | isCustom: fields[4] as bool, 25 | parentId: fields[5] as String?, 26 | subcategoryIds: (fields[6] as List?)?.cast(), 27 | ); 28 | } 29 | 30 | @override 31 | void write(BinaryWriter writer, Category obj) { 32 | writer 33 | ..writeByte(7) 34 | ..writeByte(0) 35 | ..write(obj.id) 36 | ..writeByte(1) 37 | ..write(obj.name) 38 | ..writeByte(2) 39 | ..write(obj.iconCode) 40 | ..writeByte(3) 41 | ..write(obj.colorValue) 42 | ..writeByte(4) 43 | ..write(obj.isCustom) 44 | ..writeByte(5) 45 | ..write(obj.parentId) 46 | ..writeByte(6) 47 | ..write(obj.subcategoryIds); 48 | } 49 | 50 | @override 51 | int get hashCode => typeId.hashCode; 52 | 53 | @override 54 | bool operator ==(Object other) => 55 | identical(this, other) || 56 | other is CategoryAdapter && 57 | runtimeType == other.runtimeType && 58 | typeId == other.typeId; 59 | } 60 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 17 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 18 | - platform: android 19 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 20 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 21 | - platform: ios 22 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 23 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 24 | - platform: linux 25 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 26 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 27 | - platform: macos 28 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 29 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 30 | - platform: web 31 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 32 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 33 | - platform: windows 34 | create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 35 | base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e 36 | 37 | # User provided section 38 | 39 | # List of Local paths (relative to this file) that should be 40 | # ignored by the migrate tool. 41 | # 42 | # Files that are not part of the templates will be ignored by default. 43 | unmanaged_files: 44 | - 'lib/main.dart' 45 | - 'ios/Runner.xcodeproj/project.pbxproj' 46 | -------------------------------------------------------------------------------- /analysis.txt: -------------------------------------------------------------------------------- 1 | Analyzing transaction_detail_screen.dart... 2 | 3 | error • The argument for the named parameter 'dropdownColor' was already specified • lib/screens/transaction_detail_screen.dart:436:31 • duplicate_named_argument 4 | error • The argument for the named parameter 'icon' was already specified • lib/screens/transaction_detail_screen.dart:439:31 • duplicate_named_argument 5 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/transaction_detail_screen.dart:474:29 • deprecated_member_use 6 | error • The argument for the named parameter 'padding' was already specified • lib/screens/transaction_detail_screen.dart:650:29 • duplicate_named_argument 7 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/transaction_detail_screen.dart:746:33 • deprecated_member_use 8 | info • Don't use 'BuildContext's across async gaps • lib/screens/transaction_detail_screen.dart:1634:9 • use_build_context_synchronously 9 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1974:30 • use_build_context_synchronously 10 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1991:7 • use_build_context_synchronously 11 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2496:26 • use_build_context_synchronously 12 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2497:26 • use_build_context_synchronously 13 | 14 | 10 issues found. (ran in 3.1s) 15 | -------------------------------------------------------------------------------- /lib/repositories/hive_transaction_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:hive_flutter/hive_flutter.dart'; 3 | import '../models/transaction.dart'; 4 | import 'transaction_repository.dart'; 5 | 6 | class HiveTransactionRepository implements TransactionRepository { 7 | final Box _box; 8 | 9 | HiveTransactionRepository(this._box); 10 | 11 | @override 12 | Future> getTransactions() async { 13 | return _box.values.toList(); 14 | } 15 | 16 | @override 17 | Future addTransaction(Transaction transaction) async { 18 | // Use the transaction ID as the key for direct access 19 | await _box.put(transaction.id, transaction); 20 | await _box.flush(); // Ensure data is written to disk 21 | } 22 | 23 | @override 24 | Future deleteTransaction(String id) async { 25 | if (_box.containsKey(id)) { 26 | await _box.delete(id); 27 | await _box.flush(); 28 | } else { 29 | // Fallback for legacy data where key might be an integer 30 | try { 31 | final transaction = _box.values.firstWhere((t) => t.id == id); 32 | await transaction.delete(); 33 | await _box.flush(); 34 | } catch (e) { 35 | // Transaction not found 36 | } 37 | } 38 | } 39 | 40 | @override 41 | Future updateTransaction(Transaction transaction) async { 42 | debugPrint('HiveRepo: Updating transaction ${transaction.id}'); 43 | debugPrint('HiveRepo: receiptPath = ${transaction.receiptPath}'); 44 | debugPrint('HiveRepo: notes = ${transaction.notes}'); 45 | await _box.put(transaction.id, transaction); 46 | await _box.flush(); // Ensure data is written to disk immediately 47 | 48 | // Verify the save 49 | final saved = _box.get(transaction.id); 50 | debugPrint('HiveRepo: Verified saved receiptPath = ${saved?.receiptPath}'); 51 | } 52 | 53 | @override 54 | Future clear() async { 55 | await _box.clear(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/services/biometric_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:local_auth/local_auth.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | import 'package:flutter/services.dart'; 4 | 5 | class BiometricService { 6 | final LocalAuthentication _auth = LocalAuthentication(); 7 | 8 | bool? _isSupportedCached; 9 | bool? _canCheckBiometricsCached; 10 | 11 | /// Checks if the device supports biometric authentication. 12 | /// Results are cached to avoid redundant platform calls. 13 | Future isSupported() async { 14 | if (_isSupportedCached != null) return _isSupportedCached!; 15 | try { 16 | _isSupportedCached = await _auth.isDeviceSupported(); 17 | return _isSupportedCached!; 18 | } catch (e) { 19 | debugPrint('Error checking device support: $e'); 20 | return false; 21 | } 22 | } 23 | 24 | /// Checks if biometrics are available and configured. 25 | /// Results are cached for performance. 26 | Future canCheckBiometrics() async { 27 | if (_canCheckBiometricsCached != null) return _canCheckBiometricsCached!; 28 | try { 29 | _canCheckBiometricsCached = await _auth.canCheckBiometrics; 30 | return _canCheckBiometricsCached!; 31 | } catch (e) { 32 | debugPrint('Error checking biometric availability: $e'); 33 | return false; 34 | } 35 | } 36 | 37 | /// Combined check for both hardware support and availability. 38 | Future isAvailable() async { 39 | final supported = await isSupported(); 40 | if (!supported) return false; 41 | return await canCheckBiometrics(); 42 | } 43 | 44 | /// Standardized authentication method with optimized options. 45 | Future authenticate({ 46 | required String localizedReason, 47 | bool biometricOnly = false, 48 | }) async { 49 | try { 50 | return await _auth.authenticate( 51 | localizedReason: localizedReason, 52 | options: AuthenticationOptions( 53 | stickyAuth: true, 54 | biometricOnly: biometricOnly, 55 | useErrorDialogs: true, 56 | ), 57 | ); 58 | } on PlatformException catch (e) { 59 | debugPrint('Biometric authentication error: $e'); 60 | return false; 61 | } catch (e) { 62 | debugPrint('Unexpected authentication error: $e'); 63 | return false; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/dark/pennywise/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.dark.pennywise 2 | 3 | import android.os.Bundle 4 | import android.telephony.SmsManager 5 | import android.os.Build 6 | import io.flutter.embedding.android.FlutterFragmentActivity 7 | import io.flutter.embedding.engine.FlutterEngine 8 | import io.flutter.plugin.common.MethodChannel 9 | 10 | class MainActivity : FlutterFragmentActivity() { 11 | private val CHANNEL = "pennywise/sms" 12 | 13 | override fun configureFlutterEngine(flutterEngine: FlutterEngine) { 14 | super.configureFlutterEngine(flutterEngine) 15 | 16 | MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> 17 | if (call.method == "sendSMS") { 18 | val phone = call.argument("phone") 19 | val message = call.argument("message") 20 | 21 | if (phone != null && message != null) { 22 | try { 23 | val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 24 | applicationContext.getSystemService(SmsManager::class.java) 25 | } else { 26 | @Suppress("DEPRECATION") 27 | SmsManager.getDefault() 28 | } 29 | 30 | // Split long messages into parts 31 | val parts = smsManager.divideMessage(message) 32 | if (parts.size > 1) { 33 | smsManager.sendMultipartTextMessage(phone, null, parts, null, null) 34 | } else { 35 | smsManager.sendTextMessage(phone, null, message, null, null) 36 | } 37 | 38 | result.success(true) 39 | } catch (e: Exception) { 40 | result.error("SMS_ERROR", e.message, null) 41 | } 42 | } else { 43 | result.error("INVALID_ARGS", "Phone or message is null", null) 44 | } 45 | } else { 46 | result.notImplemented() 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /android/app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-android") 4 | // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 5 | id("dev.flutter.flutter-gradle-plugin") 6 | id("com.google.gms.google-services") 7 | id("com.google.firebase.crashlytics") 8 | } 9 | 10 | import java.util.Properties 11 | import java.io.FileInputStream 12 | 13 | val keystoreProperties = Properties() 14 | val keystorePropertiesFile = rootProject.file("key.properties") 15 | if (keystorePropertiesFile.exists()) { 16 | keystoreProperties.load(FileInputStream(keystorePropertiesFile)) 17 | } 18 | 19 | android { 20 | namespace = "com.dark.pennywise" 21 | compileSdk = flutter.compileSdkVersion 22 | ndkVersion = flutter.ndkVersion 23 | 24 | compileOptions { 25 | sourceCompatibility = JavaVersion.VERSION_17 26 | targetCompatibility = JavaVersion.VERSION_17 27 | } 28 | 29 | kotlinOptions { 30 | jvmTarget = JavaVersion.VERSION_17.toString() 31 | } 32 | 33 | 34 | 35 | defaultConfig { 36 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 37 | applicationId = "com.dark.pennywise" 38 | // You can update the following values to match your application needs. 39 | // For more information, see: https://flutter.dev/to/review-gradle-config. 40 | minSdk = flutter.minSdkVersion 41 | targetSdk = flutter.targetSdkVersion 42 | versionCode = flutter.versionCode 43 | versionName = flutter.versionName 44 | } 45 | 46 | buildTypes { 47 | release { 48 | signingConfig = signingConfigs.getByName("debug") 49 | isMinifyEnabled = true 50 | isShrinkResources = true 51 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 52 | } 53 | } 54 | } 55 | 56 | dependencies { 57 | // Import the Firebase BoM 58 | implementation(platform("com.google.firebase:firebase-bom:34.6.0")) 59 | 60 | // TODO: Add the dependencies for Firebase products you want to use 61 | // When using the BoM, don't specify versions in Firebase dependencies 62 | implementation("com.google.firebase:firebase-analytics") 63 | } 64 | 65 | flutter { 66 | source = "../.." 67 | } 68 | -------------------------------------------------------------------------------- /lib/models/account.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hive/hive.dart'; 3 | 4 | part 'account.g.dart'; 5 | 6 | @HiveType(typeId: 6) 7 | class Account extends HiveObject { 8 | @HiveField(0) 9 | final String id; 10 | 11 | @HiveField(1) 12 | String name; 13 | 14 | @HiveField(2) 15 | int colorValue; 16 | 17 | @HiveField(3) 18 | int iconCode; 19 | 20 | @HiveField(4) 21 | final DateTime createdAt; 22 | 23 | @HiveField(5) 24 | bool isDefault; 25 | 26 | @HiveField(6) 27 | bool showSmsTransactions; 28 | 29 | Account({ 30 | required this.id, 31 | required this.name, 32 | this.colorValue = 0xFF6C5CE7, // Default purple 33 | this.iconCode = 0xe04b, // account_balance_wallet icon code 34 | DateTime? createdAt, 35 | this.isDefault = false, 36 | this.showSmsTransactions = false, 37 | }) : createdAt = createdAt ?? DateTime.now(); 38 | 39 | Color get color => Color(colorValue); 40 | IconData get icon => IconData(iconCode, fontFamily: 'MaterialIcons'); 41 | 42 | Map toJson() { 43 | return { 44 | 'id': id, 45 | 'name': name, 46 | 'colorValue': colorValue, 47 | 'iconCode': iconCode, 48 | 'createdAt': createdAt.toIso8601String(), 49 | 'isDefault': isDefault, 50 | 'showSmsTransactions': showSmsTransactions, 51 | }; 52 | } 53 | 54 | factory Account.fromJson(Map json) { 55 | return Account( 56 | id: json['id'], 57 | name: json['name'], 58 | colorValue: json['colorValue'] ?? 0xFF6C5CE7, 59 | iconCode: json['iconCode'] ?? 0xe04b, 60 | createdAt: json['createdAt'] != null 61 | ? DateTime.parse(json['createdAt']) 62 | : DateTime.now(), 63 | isDefault: json['isDefault'] ?? false, 64 | showSmsTransactions: json['showSmsTransactions'] ?? false, 65 | ); 66 | } 67 | 68 | Account copyWith({ 69 | String? id, 70 | String? name, 71 | int? colorValue, 72 | int? iconCode, 73 | DateTime? createdAt, 74 | bool? isDefault, 75 | bool? showSmsTransactions, 76 | }) { 77 | return Account( 78 | id: id ?? this.id, 79 | name: name ?? this.name, 80 | colorValue: colorValue ?? this.colorValue, 81 | iconCode: iconCode ?? this.iconCode, 82 | createdAt: createdAt ?? this.createdAt, 83 | isDefault: isDefault ?? this.isDefault, 84 | showSmsTransactions: showSmsTransactions ?? this.showSmsTransactions, 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_core/firebase_core.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:hive_flutter/hive_flutter.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'models/transaction.dart'; 6 | import 'models/budget.dart'; 7 | import 'models/category.dart'; 8 | import 'models/loan.dart'; 9 | import 'models/goal.dart'; 10 | import 'models/account.dart'; 11 | import 'screens/home_screen.dart'; 12 | import 'screens/onboarding_screen.dart'; 13 | import 'screens/lock_screen.dart'; 14 | import 'utils/app_theme.dart'; 15 | import 'providers/money_provider.dart'; 16 | 17 | void main() async { 18 | WidgetsFlutterBinding.ensureInitialized(); 19 | await Firebase.initializeApp(); 20 | await Hive.initFlutter(); 21 | Hive.registerAdapter(TransactionAdapter()); 22 | Hive.registerAdapter(BudgetAdapter()); 23 | Hive.registerAdapter(CategoryAdapter()); 24 | Hive.registerAdapter(LoanAdapter()); 25 | Hive.registerAdapter(LoanTypeAdapter()); 26 | Hive.registerAdapter(GoalAdapter()); 27 | Hive.registerAdapter(AccountAdapter()); 28 | await Hive.openBox('transactions'); 29 | await Hive.openBox( 30 | 'deletedSmsIds', 31 | ); // Blocklist for deleted SMS transactions 32 | final settingsBox = await Hive.openBox('settings'); 33 | final userName = settingsBox.get('userName'); 34 | 35 | runApp( 36 | MultiProvider( 37 | providers: [ChangeNotifierProvider(create: (_) => MoneyProvider())], 38 | child: PennyWiseApp( 39 | initialRoute: userName != null ? '/home' : '/onboarding', 40 | ), 41 | ), 42 | ); 43 | } 44 | 45 | class PennyWiseApp extends StatelessWidget { 46 | final String initialRoute; 47 | const PennyWiseApp({super.key, required this.initialRoute}); 48 | 49 | @override 50 | Widget build(BuildContext context) { 51 | return Consumer( 52 | builder: (context, provider, child) { 53 | return MaterialApp( 54 | title: 'PennyWise', 55 | debugShowCheckedModeBanner: false, 56 | theme: AppTheme.getTheme(provider.appThemeMode), 57 | home: LockScreen( 58 | isEnabled: provider.biometricLockEnabled && initialRoute == '/home', 59 | child: initialRoute == '/home' 60 | ? const HomeScreen() 61 | : const OnboardingScreen(), 62 | ), 63 | routes: { 64 | '/onboarding': (context) => const OnboardingScreen(), 65 | '/home': (context) => LockScreen( 66 | isEnabled: provider.biometricLockEnabled, 67 | child: const HomeScreen(), 68 | ), 69 | }, 70 | ); 71 | }, 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/services/auth_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:firebase_auth/firebase_auth.dart'; 3 | import 'package:google_sign_in/google_sign_in.dart'; 4 | import 'package:flutter/foundation.dart'; 5 | 6 | class AuthService { 7 | final FirebaseAuth _auth = FirebaseAuth.instance; 8 | final FirebaseFirestore _firestore = FirebaseFirestore.instance; 9 | final GoogleSignIn _googleSignIn = GoogleSignIn(); 10 | 11 | // Stream of auth changes 12 | Stream get authStateChanges => _auth.authStateChanges(); 13 | 14 | // Current user 15 | User? get currentUser => _auth.currentUser; 16 | 17 | // Sign in with Google 18 | Future signInWithGoogle() async { 19 | try { 20 | final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); 21 | if (googleUser == null) return null; // User canceled 22 | 23 | final GoogleSignInAuthentication googleAuth = 24 | await googleUser.authentication; 25 | 26 | final OAuthCredential credential = GoogleAuthProvider.credential( 27 | accessToken: googleAuth.accessToken, 28 | idToken: googleAuth.idToken, 29 | ); 30 | 31 | final UserCredential userCredential = await _auth.signInWithCredential( 32 | credential, 33 | ); 34 | 35 | if (userCredential.user != null) { 36 | await _syncUserToFirestore(userCredential.user!); 37 | } 38 | 39 | return userCredential; 40 | } catch (e) { 41 | debugPrint("Error signing in with Google: $e"); 42 | rethrow; 43 | } 44 | } 45 | 46 | Future _syncUserToFirestore(User user) async { 47 | try { 48 | final userDoc = _firestore.collection('users').doc(user.uid); 49 | final snapshot = await userDoc.get(); 50 | 51 | if (!snapshot.exists) { 52 | // Create new user document 53 | await userDoc.set({ 54 | 'uid': user.uid, 55 | 'email': user.email, 56 | 'displayName': user.displayName, 57 | 'photoURL': user.photoURL, 58 | 'createdAt': FieldValue.serverTimestamp(), 59 | 'lastLogin': FieldValue.serverTimestamp(), 60 | }); 61 | } else { 62 | // Update existing user document 63 | await userDoc.update({ 64 | 'lastLogin': FieldValue.serverTimestamp(), 65 | 'displayName': user.displayName, // Update in case changed 66 | 'photoURL': user.photoURL, 67 | }); 68 | } 69 | } catch (e) { 70 | debugPrint("Error syncing user to Firestore: $e"); 71 | // Don't rethrow, as login was successful 72 | } 73 | } 74 | 75 | // Sign out 76 | Future signOut() async { 77 | await _googleSignIn.signOut(); 78 | await _auth.signOut(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/models/transaction.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'transaction.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class TransactionAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 0; 12 | 13 | @override 14 | Transaction read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return Transaction( 20 | id: fields[0] as String, 21 | title: fields[1] as String, 22 | amount: fields[2] as double, 23 | date: fields[3] as DateTime, 24 | isExpense: fields[4] as bool, 25 | category: fields[5] as String, 26 | accountId: fields[6] as String, 27 | userId: fields[7] as String?, 28 | smsBody: fields[8] as String?, 29 | referenceNumber: fields[9] as String?, 30 | bankName: fields[10] as String?, 31 | accountLast4: fields[11] as String?, 32 | isExcluded: fields[12] as bool, 33 | notes: fields[13] as String?, 34 | receiptPath: fields[14] as String?, 35 | receiptBase64: fields[15] as String?, 36 | subcategory: fields[16] as String?, 37 | ); 38 | } 39 | 40 | @override 41 | void write(BinaryWriter writer, Transaction obj) { 42 | writer 43 | ..writeByte(17) 44 | ..writeByte(0) 45 | ..write(obj.id) 46 | ..writeByte(1) 47 | ..write(obj.title) 48 | ..writeByte(2) 49 | ..write(obj.amount) 50 | ..writeByte(3) 51 | ..write(obj.date) 52 | ..writeByte(4) 53 | ..write(obj.isExpense) 54 | ..writeByte(5) 55 | ..write(obj.category) 56 | ..writeByte(6) 57 | ..write(obj.accountId) 58 | ..writeByte(7) 59 | ..write(obj.userId) 60 | ..writeByte(8) 61 | ..write(obj.smsBody) 62 | ..writeByte(9) 63 | ..write(obj.referenceNumber) 64 | ..writeByte(10) 65 | ..write(obj.bankName) 66 | ..writeByte(11) 67 | ..write(obj.accountLast4) 68 | ..writeByte(12) 69 | ..write(obj.isExcluded) 70 | ..writeByte(13) 71 | ..write(obj.notes) 72 | ..writeByte(14) 73 | ..write(obj.receiptPath) 74 | ..writeByte(15) 75 | ..write(obj.receiptBase64) 76 | ..writeByte(16) 77 | ..write(obj.subcategory); 78 | } 79 | 80 | @override 81 | int get hashCode => typeId.hashCode; 82 | 83 | @override 84 | bool operator ==(Object other) => 85 | identical(this, other) || 86 | other is TransactionAdapter && 87 | runtimeType == other.runtimeType && 88 | typeId == other.typeId; 89 | } 90 | -------------------------------------------------------------------------------- /lib/models/loan.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'loan.dart'; 4 | 5 | // ************************************************************************** 6 | // TypeAdapterGenerator 7 | // ************************************************************************** 8 | 9 | class LoanAdapter extends TypeAdapter { 10 | @override 11 | final int typeId = 4; 12 | 13 | @override 14 | Loan read(BinaryReader reader) { 15 | final numOfFields = reader.readByte(); 16 | final fields = { 17 | for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), 18 | }; 19 | return Loan( 20 | id: fields[0] as String, 21 | title: fields[1] as String, 22 | totalAmount: fields[2] as double, 23 | paidAmount: fields[3] as double, 24 | type: fields[4] as LoanType, 25 | startDate: fields[5] as DateTime, 26 | dueDate: fields[6] as DateTime?, 27 | notes: fields[7] as String?, 28 | ); 29 | } 30 | 31 | @override 32 | void write(BinaryWriter writer, Loan obj) { 33 | writer 34 | ..writeByte(8) 35 | ..writeByte(0) 36 | ..write(obj.id) 37 | ..writeByte(1) 38 | ..write(obj.title) 39 | ..writeByte(2) 40 | ..write(obj.totalAmount) 41 | ..writeByte(3) 42 | ..write(obj.paidAmount) 43 | ..writeByte(4) 44 | ..write(obj.type) 45 | ..writeByte(5) 46 | ..write(obj.startDate) 47 | ..writeByte(6) 48 | ..write(obj.dueDate) 49 | ..writeByte(7) 50 | ..write(obj.notes); 51 | } 52 | 53 | @override 54 | int get hashCode => typeId.hashCode; 55 | 56 | @override 57 | bool operator ==(Object other) => 58 | identical(this, other) || 59 | other is LoanAdapter && 60 | runtimeType == other.runtimeType && 61 | typeId == other.typeId; 62 | } 63 | 64 | class LoanTypeAdapter extends TypeAdapter { 65 | @override 66 | final int typeId = 3; 67 | 68 | @override 69 | LoanType read(BinaryReader reader) { 70 | switch (reader.readByte()) { 71 | case 0: 72 | return LoanType.given; 73 | case 1: 74 | return LoanType.taken; 75 | default: 76 | return LoanType.given; 77 | } 78 | } 79 | 80 | @override 81 | void write(BinaryWriter writer, LoanType obj) { 82 | switch (obj) { 83 | case LoanType.given: 84 | writer.writeByte(0); 85 | break; 86 | case LoanType.taken: 87 | writer.writeByte(1); 88 | break; 89 | } 90 | } 91 | 92 | @override 93 | int get hashCode => typeId.hashCode; 94 | 95 | @override 96 | bool operator ==(Object other) => 97 | identical(this, other) || 98 | other is LoanTypeAdapter && 99 | runtimeType == other.runtimeType && 100 | typeId == other.typeId; 101 | } 102 | -------------------------------------------------------------------------------- /lib/models/transaction.dart: -------------------------------------------------------------------------------- 1 | import 'package:hive/hive.dart'; 2 | 3 | part 'transaction.g.dart'; 4 | 5 | @HiveType(typeId: 0) 6 | class Transaction extends HiveObject { 7 | @HiveField(0) 8 | final String id; 9 | 10 | @HiveField(1) 11 | final String title; 12 | 13 | @HiveField(2) 14 | final double amount; 15 | 16 | @HiveField(3) 17 | final DateTime date; 18 | 19 | @HiveField(4) 20 | final bool isExpense; 21 | 22 | @HiveField(5) 23 | final String category; 24 | 25 | @HiveField(6) 26 | final String accountId; 27 | 28 | @HiveField(7) 29 | final String? userId; 30 | 31 | @HiveField(8) 32 | final String? smsBody; 33 | 34 | @HiveField(9) 35 | final String? referenceNumber; 36 | 37 | @HiveField(10) 38 | final String? bankName; 39 | 40 | @HiveField(11) 41 | final String? accountLast4; 42 | 43 | @HiveField(12) 44 | final bool isExcluded; 45 | 46 | @HiveField(13) 47 | final String? notes; 48 | 49 | @HiveField(14) 50 | final String? receiptPath; 51 | 52 | @HiveField(15) 53 | final String? receiptBase64; 54 | 55 | @HiveField(16) 56 | final String? subcategory; 57 | 58 | Transaction({ 59 | required this.id, 60 | required this.title, 61 | required this.amount, 62 | required this.date, 63 | required this.isExpense, 64 | required this.category, 65 | this.accountId = 'default', 66 | this.userId, 67 | this.smsBody, 68 | this.referenceNumber, 69 | this.bankName, 70 | this.accountLast4, 71 | this.isExcluded = false, 72 | this.notes, 73 | this.receiptPath, 74 | this.receiptBase64, 75 | this.subcategory, 76 | }); 77 | 78 | Map toJson() { 79 | return { 80 | 'id': id, 81 | 'title': title, 82 | 'amount': amount, 83 | 'date': date.toIso8601String(), 84 | 'isExpense': isExpense, 85 | 'category': category, 86 | 'accountId': accountId, 87 | 'userId': userId, 88 | 'smsBody': smsBody, 89 | 'referenceNumber': referenceNumber, 90 | 'bankName': bankName, 91 | 'accountLast4': accountLast4, 92 | 'isExcluded': isExcluded, 93 | 'notes': notes, 94 | 'receiptPath': receiptPath, 95 | 'receiptBase64': receiptBase64, 96 | 'subcategory': subcategory, 97 | }; 98 | } 99 | 100 | factory Transaction.fromJson(Map json) { 101 | return Transaction( 102 | id: json['id'], 103 | title: json['title'], 104 | amount: json['amount'], 105 | date: DateTime.parse(json['date']), 106 | isExpense: json['isExpense'], 107 | category: json['category'], 108 | accountId: json['accountId'] ?? 'default', 109 | userId: json['userId'], 110 | smsBody: json['smsBody'], 111 | referenceNumber: json['referenceNumber'], 112 | bankName: json['bankName'], 113 | accountLast4: json['accountLast4'], 114 | isExcluded: json['isExcluded'] ?? false, 115 | notes: json['notes'], 116 | receiptPath: json['receiptPath'], 117 | receiptBase64: json['receiptBase64'], 118 | subcategory: json['subcategory'], 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib/utils/app_theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | 4 | enum AppThemeMode { defaultDark, amoled, light } 5 | 6 | class AppTheme { 7 | static const Color background = Color(0xFF151026); // Dark Navy Purple 8 | static const Color amoledBackground = Color(0xFF000000); // Pitch Black 9 | static const Color lightBackground = Color(0xFFF8FAFC); // White/Slate 10 | 11 | static const Color surface = Color(0xFF1E293B); 12 | static const Color amoledSurface = Color(0xFF000000); 13 | static const Color lightSurface = Color(0xFFFFFFFF); 14 | 15 | static const Color primary = Color(0xFF6366F1); // Indigo 16 | static const Color income = Color(0xFF10B981); // Emerald 17 | static const Color expense = Color(0xFFEF4444); // Red 18 | static const Color textPrimary = Color(0xFFF8FAFC); 19 | static const Color textSecondary = Color(0xFF94A3B8); 20 | static const Color lightTextPrimary = Color(0xFF0F172A); 21 | static const Color lightTextSecondary = Color(0xFF475569); 22 | 23 | static ThemeData getTheme(AppThemeMode mode) { 24 | switch (mode) { 25 | case AppThemeMode.amoled: 26 | return _buildTheme( 27 | amoledBackground, 28 | amoledSurface, 29 | Brightness.dark, 30 | isAmoled: true, 31 | ); 32 | case AppThemeMode.light: 33 | return _buildTheme(lightBackground, lightSurface, Brightness.light); 34 | case AppThemeMode.defaultDark: 35 | return _buildTheme(background, surface, Brightness.dark); 36 | } 37 | } 38 | 39 | static ThemeData _buildTheme( 40 | Color bg, 41 | Color surf, 42 | Brightness brightness, { 43 | bool isAmoled = false, 44 | }) { 45 | final bool isLight = brightness == Brightness.light; 46 | final primaryText = isLight ? lightTextPrimary : textPrimary; 47 | final secondaryText = isLight ? lightTextSecondary : textSecondary; 48 | 49 | return ThemeData( 50 | useMaterial3: true, 51 | brightness: brightness, 52 | scaffoldBackgroundColor: bg, 53 | primaryColor: primary, 54 | canvasColor: bg, 55 | cardColor: surf, 56 | dividerColor: isLight 57 | ? Colors.black.withOpacity(0.05) 58 | : Colors.white.withOpacity(0.1), 59 | shadowColor: isLight 60 | ? Colors.black.withOpacity(0.1) 61 | : Colors.black.withOpacity(0.3), 62 | colorScheme: ColorScheme( 63 | brightness: brightness, 64 | primary: primary, 65 | onPrimary: Colors.white, 66 | secondary: primary, 67 | onSecondary: Colors.white, 68 | error: expense, 69 | onError: Colors.white, 70 | surface: surf, 71 | onSurface: primaryText, 72 | surfaceContainerHighest: isLight 73 | ? Colors.grey.withOpacity(0.05) 74 | : (isAmoled ? Colors.black : Colors.white.withOpacity(0.05)), 75 | ), 76 | textTheme: 77 | GoogleFonts.outfitTextTheme( 78 | isLight ? ThemeData.light().textTheme : ThemeData.dark().textTheme, 79 | ).apply( 80 | bodyColor: primaryText, 81 | displayColor: primaryText, 82 | decorationColor: secondaryText, 83 | ), 84 | iconTheme: IconThemeData(color: isLight ? lightTextPrimary : textPrimary), 85 | cardTheme: CardThemeData( 86 | color: surf, 87 | elevation: 0, 88 | shape: RoundedRectangleBorder( 89 | borderRadius: BorderRadius.circular(16), 90 | side: BorderSide( 91 | color: isLight 92 | ? Colors.black.withOpacity(0.05) 93 | : Colors.white.withOpacity(0.1), 94 | width: 1, 95 | ), 96 | ), 97 | ), 98 | ); 99 | } 100 | 101 | static ThemeData get darkTheme => getTheme(AppThemeMode.defaultDark); 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💰 PennyWise 2 | 3 | **Your Smart Personal Finance Manager** 4 | 5 | PennyWise is a comprehensive, modern expense management application built with Flutter. It combines the privacy of local storage with the convenience of cloud sync, helping you track expenses, manage budgets, monitor loans, and achieve your financial goals with a stunning, dark-themed interface. 6 | 7 | ## ✨ Key Features 8 | 9 | ### 🤖 Smart Automation 10 | - **SMS Expense Tracking** - Automatically detects and records transaction SMS messages from banks (Local processing only for privacy). 11 | 12 | ### 💸 Complete Financial Management 13 | - **Transaction Tracking** - Record income and expenses with ease. 14 | - **Loan Management** - Keep track of money lent to friends (`Given`) and borrowed (`Taken`). 15 | - **Financial Goals** - Set savings targets (e.g., "New Laptop", "Vacation") and track your progress. 16 | - **Net Worth Overview** - Real-time calculation of your total financial health. 17 | 18 | ### 📊 Analytics & Insights 19 | - **Interactive Charts** - Visual breakdown of spending by category. 20 | - **Spending Trends** - Analyze your daily spending patterns. 21 | - **Budget Planning** - Set monthly limits for specific categories and get alerts. 22 | 23 | ### ☁️ Hybrid Architecture 24 | - **Cloud Sync** - Securely sync your data across devices using Firebase (Google Sign-In). 25 | - **Offline Capable** - Works perfectly offline with Hive local database. 26 | - **Privacy Focused** - SMS data is stored **locally only** and never uploaded to the cloud. 27 | 28 | ### 🎨 Premium Experience 29 | - **Modern Dark UI** - Sleek, gradient-based design with smooth animations. 30 | - **Customization** - Personalize your profile, currency (₹, $, €, etc.), and categories. 31 | - **Biometric Lock** - Secure your financial data with Fingerprint/Face ID. 32 | 33 | ## 🛠️ Tech Stack 34 | 35 | - **Framework**: Flutter 3.10+ 36 | - **Architecture**: Provider (State Management) 37 | - **Backend**: 38 | - **Firebase**: Auth, Firestore (Cloud Sync), Crashlytics 39 | - **Hive**: Local NoSQL Database (Offline cache & SMS storage) 40 | - **Key Libraries**: 41 | - `fl_chart`: For beautiful analytics graphs 42 | - `flutter_animate`: For smooth UI transitions 43 | - `flutter_sms_inbox`: For reading transaction SMS 44 | - `local_auth`: For biometric security 45 | - `google_fonts`: For modern typography 46 | 47 | ## 🚀 Getting Started 48 | 49 | ### Prerequisites 50 | - Flutter SDK (3.10.1 or higher) 51 | - Dart SDK 52 | - Firebase Project (for cloud features) 53 | 54 | ### Installation 55 | 56 | 1. **Clone the repository** 57 | ```bash 58 | git clone https://github.com/Bhanu7773-dev/PennyWise.git 59 | cd PennyWise 60 | ``` 61 | 62 | 2. **Install dependencies** 63 | ```bash 64 | flutter pub get 65 | ``` 66 | 67 | 3. **Generate Hive adapters** 68 | ```bash 69 | flutter pub run build_runner build 70 | ``` 71 | 72 | 4. **Run the app** 73 | ```bash 74 | flutter run 75 | ``` 76 | 77 | ## 📱 Screenshots 78 | 79 |

80 | Home Screen 81 | Analytics Overview 82 |

83 | 84 |

85 | Analytics Breakdown 86 | Analytics Trends 87 |

88 | 89 |

90 | Advanced Features 91 | Settings 92 |

93 | 94 | ## 🤝 Contributing 95 | 96 | Contributions are welcome! Please feel free to submit a Pull Request. 97 | 98 | 1. Fork the Project 99 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 100 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 101 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 102 | 5. Open a Pull Request 103 | 104 | ## 👨‍💻 Author 105 | 106 | **Bhanu Pratap Singh** 107 | - GitHub: [@Bhanu7773-dev](https://github.com/Bhanu7773-dev) 108 | - Telegram: [@darkdevil7773](https://t.me/darkdevil7773) 109 | 110 | ## 🌟 Support 111 | 112 | Give a ⭐️ if this project helped you! 113 | 114 | --- 115 | 116 | **Made with ❤️ and Flutter** 117 | -------------------------------------------------------------------------------- /lib/widgets/custom_bottom_nav_bar.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:provider/provider.dart'; 4 | import '../providers/money_provider.dart'; 5 | import '../utils/app_theme.dart'; 6 | 7 | class CustomBottomNavBar extends StatelessWidget { 8 | final int selectedIndex; 9 | final Function(int) onItemSelected; 10 | 11 | const CustomBottomNavBar({ 12 | super.key, 13 | this.selectedIndex = 0, 14 | required this.onItemSelected, 15 | }); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final provider = Provider.of(context); 20 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 21 | final isLight = provider.appThemeMode == AppThemeMode.light; 22 | 23 | return ClipRRect( 24 | borderRadius: BorderRadius.circular(32), 25 | child: BackdropFilter( 26 | filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), 27 | child: Container( 28 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 29 | decoration: BoxDecoration( 30 | color: isAmoled 31 | ? Colors.black.withOpacity(0.3) 32 | : (isLight 33 | ? Colors.white.withOpacity(0.2) 34 | : Theme.of(context).cardColor.withOpacity(0.8)), 35 | borderRadius: BorderRadius.circular(32), 36 | border: Border.all( 37 | color: isAmoled 38 | ? Colors.white 39 | : (isLight 40 | ? Colors.black.withOpacity(0.5) 41 | : Theme.of(context).dividerColor), 42 | width: 1, 43 | ), 44 | ), 45 | child: Stack( 46 | children: [ 47 | // Animated Selector Background 48 | AnimatedPositioned( 49 | duration: const Duration(milliseconds: 300), 50 | curve: Curves.easeInOut, 51 | left: selectedIndex * 64.0, // 48 (icon+padding) + 16 (spacing) 52 | top: 0, 53 | bottom: 0, 54 | child: Container( 55 | width: 48, 56 | decoration: BoxDecoration( 57 | color: isAmoled || isLight 58 | ? (isLight ? Colors.black : Colors.white) 59 | : Theme.of(context).primaryColor, 60 | borderRadius: BorderRadius.circular(20), 61 | ), 62 | ), 63 | ), 64 | 65 | // Icons Row 66 | Row( 67 | mainAxisSize: MainAxisSize.min, 68 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 69 | children: [ 70 | _buildNavItem(context, 0, Icons.home_rounded, 'Home'), 71 | const SizedBox(width: 16), 72 | _buildNavItem( 73 | context, 74 | 1, 75 | Icons.bar_chart_rounded, 76 | 'Analytics', 77 | ), 78 | const SizedBox(width: 16), 79 | _buildNavItem( 80 | context, 81 | 2, 82 | Icons.auto_graph_rounded, 83 | 'Advance', 84 | ), 85 | const SizedBox(width: 16), 86 | _buildNavItem(context, 3, Icons.settings_rounded, 'Settings'), 87 | ], 88 | ), 89 | ], 90 | ), 91 | ), 92 | ), 93 | ); 94 | } 95 | 96 | Widget _buildNavItem( 97 | BuildContext context, 98 | int index, 99 | IconData icon, 100 | String label, 101 | ) { 102 | final isSelected = selectedIndex == index; 103 | final theme = Theme.of(context); 104 | final provider = Provider.of(context, listen: false); 105 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 106 | 107 | return GestureDetector( 108 | behavior: HitTestBehavior.opaque, 109 | onTap: () => onItemSelected(index), 110 | child: Container( 111 | width: 48, 112 | height: 48, 113 | alignment: Alignment.center, 114 | child: Icon( 115 | icon, 116 | color: isSelected 117 | ? (isAmoled ? Colors.black : Colors.white) 118 | : theme.textTheme.bodyMedium?.color?.withOpacity(0.5), 119 | size: 24, 120 | ), 121 | ), 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 34 | 43 | 47 | 51 | 52 | 53 | 54 | 55 | 56 | 58 | 61 | 62 | 63 | 68 | 71 | 72 | 73 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: pennywise 2 | description: "A new Flutter project." 3 | # The following line prevents the package from being accidentally published to 4 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 6 | 7 | # The following defines the version and build number for your application. 8 | # A version number is three numbers separated by dots, like 1.2.43 9 | # followed by an optional build number separated by a +. 10 | # Both the version and the builder number may be overridden in flutter 11 | # build by specifying --build-name and --build-number, respectively. 12 | # In Android, build-name is used as versionName while build-number used as versionCode. 13 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 14 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 | # Read more about iOS versioning at 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 | # In Windows, build-name is used as the major, minor, and patch parts 18 | # of the product and file versions while build-number is used as the build suffix. 19 | version: 1.0.0+1 20 | 21 | environment: 22 | sdk: ^3.10.1 23 | 24 | # Dependencies specify other packages that your package needs in order to work. 25 | # To automatically upgrade your package dependencies to the latest versions 26 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 27 | # dependencies can be manually updated by changing the version numbers below to 28 | # the latest version available on pub.dev. To see which dependencies have newer 29 | # versions available, run `flutter pub outdated`. 30 | dependencies: 31 | flutter: 32 | sdk: flutter 33 | 34 | # The following adds the Cupertino Icons font to your application. 35 | # Use with the CupertinoIcons class for iOS style icons. 36 | cupertino_icons: ^1.0.8 37 | provider: ^6.1.5+1 38 | hive: ^2.2.3 39 | hive_flutter: ^1.1.0 40 | fl_chart: ^1.1.1 41 | flutter_animate: ^4.5.2 42 | google_fonts: ^6.3.2 43 | intl: ^0.20.2 44 | vibration: ^3.1.4 45 | uuid: ^4.5.2 46 | local_auth: ^2.2.0 47 | csv: ^6.0.0 48 | pdf: ^3.11.1 49 | path_provider: ^2.1.4 50 | permission_handler: ^11.3.1 51 | flutter_launcher_icons: ^0.14.4 52 | 53 | # Firebase Dependencies 54 | firebase_core: ^3.10.0 55 | firebase_auth: ^5.4.0 56 | cloud_firestore: ^5.6.0 57 | firebase_crashlytics: ^4.3.0 58 | firebase_messaging: ^15.2.0 59 | google_sign_in: ^6.2.2 60 | flutter_sms_inbox: ^1.0.4 61 | flutter_contacts: ^1.1.9+2 62 | image_picker: ^1.0.7 63 | file_picker: ^10.3.7 64 | http: ^1.6.0 65 | package_info_plus: ^9.0.0 66 | url_launcher: ^6.3.2 67 | dio: ^5.9.0 68 | open_filex: ^4.7.0 69 | 70 | # Google Drive 71 | googleapis: ^13.2.0 72 | extension_google_sign_in_as_googleapis_auth: ^2.0.12 73 | 74 | dev_dependencies: 75 | flutter_test: 76 | sdk: flutter 77 | 78 | # The "flutter_lints" package below contains a set of recommended lints to 79 | # encourage good coding practices. The lint set provided by the package is 80 | # activated in the `analysis_options.yaml` file located at the root of your 81 | # package. See that file for information about deactivating specific lint 82 | # rules and activating additional ones. 83 | flutter_lints: ^6.0.0 84 | hive_generator: ^2.0.1 85 | build_runner: ^2.4.13 86 | 87 | # Flutter Launcher Icons Configuration 88 | flutter_launcher_icons: 89 | android: true 90 | ios: false 91 | image_path: "assets/icon.png" 92 | adaptive_icon_background: "#00000000" # Transparent background 93 | adaptive_icon_foreground: "assets/icon.png" # Your icon as foreground 94 | remove_alpha_ios: false 95 | 96 | # For information on the generic Dart part of this file, see the 97 | # following page: https://dart.dev/tools/pub/pubspec 98 | 99 | # The following section is specific to Flutter packages. 100 | flutter: 101 | 102 | # The following line ensures that the Material Icons font is 103 | # included with your application, so that you can use the icons in 104 | # the material Icons class. 105 | uses-material-design: true 106 | 107 | # To add assets to your application, add an assets section, like this: 108 | assets: 109 | - assets/icon.png 110 | - assets/fonts/ 111 | 112 | # An image asset can refer to one or more resolution-specific "variants", see 113 | # https://flutter.dev/to/resolution-aware-images 114 | 115 | # For details regarding adding assets from package dependencies, see 116 | # https://flutter.dev/to/asset-from-package 117 | 118 | # To add custom fonts to your application, add a fonts section here, 119 | # in this "flutter" section. Each entry in this list should have a 120 | # "family" key with the font family name, and a "fonts" key with a 121 | # list giving the asset and other descriptors for the font. For 122 | # example: 123 | # fonts: 124 | # - family: Schyler 125 | # fonts: 126 | # - asset: fonts/Schyler-Regular.ttf 127 | # - asset: fonts/Schyler-Italic.ttf 128 | # style: italic 129 | # - family: Trajan Pro 130 | # fonts: 131 | # - asset: fonts/TrajanPro.ttf 132 | # - asset: fonts/TrajanPro_Bold.ttf 133 | # weight: 700 134 | # 135 | # For details regarding fonts from package dependencies, 136 | # see https://flutter.dev/to/font-from-package 137 | -------------------------------------------------------------------------------- /lib/widgets/theme_toggle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:vibration/vibration.dart'; 4 | import '../providers/money_provider.dart'; 5 | import '../utils/app_theme.dart'; 6 | import '../widgets/theme_reveal.dart'; // Import this 7 | 8 | class TriThemeToggle extends StatelessWidget { 9 | const TriThemeToggle({super.key}); 10 | 11 | void _vibrate() async { 12 | if (await Vibration.hasVibrator() == true) { 13 | Vibration.vibrate(duration: 10); 14 | } 15 | } 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final provider = Provider.of(context); 20 | final currentMode = provider.appThemeMode; 21 | final theme = Theme.of(context); 22 | 23 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 24 | 25 | return Container( 26 | width: 100, 27 | height: 36, 28 | decoration: BoxDecoration( 29 | color: isAmoled || provider.appThemeMode == AppThemeMode.light 30 | ? Colors.transparent 31 | : theme.colorScheme.surfaceContainerHighest, 32 | borderRadius: BorderRadius.circular(20), 33 | border: Border.all( 34 | color: isAmoled || provider.appThemeMode == AppThemeMode.light 35 | ? theme.iconTheme.color!.withOpacity(0.5) 36 | : theme.dividerColor, 37 | ), 38 | ), 39 | child: Stack( 40 | children: [ 41 | // Animated Thumb 42 | AnimatedAlign( 43 | duration: const Duration(milliseconds: 250), 44 | curve: Curves.easeOutBack, 45 | alignment: _getAlignment(currentMode), 46 | child: Container( 47 | width: 30, 48 | height: 30, 49 | margin: const EdgeInsets.symmetric(horizontal: 3), 50 | decoration: BoxDecoration( 51 | color: isAmoled 52 | ? Colors.white 53 | : (provider.appThemeMode == AppThemeMode.light 54 | ? Colors.black 55 | : theme.primaryColor), 56 | borderRadius: BorderRadius.circular(15), 57 | boxShadow: [ 58 | BoxShadow( 59 | color: 60 | (isAmoled 61 | ? Colors.white 62 | : (provider.appThemeMode == AppThemeMode.light 63 | ? Colors.black 64 | : theme.primaryColor)) 65 | .withOpacity(0.3), 66 | blurRadius: 8, 67 | offset: const Offset(0, 2), 68 | ), 69 | ], 70 | ), 71 | ), 72 | ), 73 | // Interactive areas 74 | Row( 75 | children: [ 76 | _buildToggleItem( 77 | context, 78 | AppThemeMode.defaultDark, 79 | Icons.contrast, 80 | provider, 81 | ), 82 | _buildToggleItem( 83 | context, 84 | AppThemeMode.amoled, 85 | Icons.nightlight_round, 86 | provider, 87 | ), 88 | _buildToggleItem( 89 | context, 90 | AppThemeMode.light, 91 | Icons.light_mode_outlined, 92 | provider, 93 | ), 94 | ], 95 | ), 96 | ], 97 | ), 98 | ); 99 | } 100 | 101 | Alignment _getAlignment(AppThemeMode mode) { 102 | switch (mode) { 103 | case AppThemeMode.defaultDark: 104 | return Alignment.centerLeft; 105 | case AppThemeMode.amoled: 106 | return Alignment.center; 107 | case AppThemeMode.light: 108 | return Alignment.centerRight; 109 | } 110 | } 111 | 112 | Widget _buildToggleItem( 113 | BuildContext context, 114 | AppThemeMode mode, 115 | IconData icon, 116 | MoneyProvider provider, 117 | ) { 118 | final isSelected = provider.appThemeMode == mode; 119 | final theme = Theme.of(context); 120 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 121 | return Expanded( 122 | child: GestureDetector( 123 | behavior: HitTestBehavior.opaque, 124 | // Capture tap details to get position 125 | onTapUp: (details) { 126 | if (!isSelected) { 127 | _vibrate(); 128 | _changeTheme(context, provider, mode, details.globalPosition); 129 | } 130 | }, 131 | child: Center( 132 | child: Icon( 133 | icon, 134 | size: 16, 135 | color: isSelected 136 | ? (isAmoled ? Colors.black : Colors.white) 137 | : theme.textTheme.bodyMedium?.color?.withOpacity(0.4), 138 | ), 139 | ), 140 | ), 141 | ); 142 | } 143 | 144 | void _changeTheme( 145 | BuildContext context, 146 | MoneyProvider provider, 147 | AppThemeMode mode, 148 | Offset tapPosition, 149 | ) { 150 | // Try to find the ThemeRevealController 151 | try { 152 | final controller = ThemeRevealController.of(context); 153 | controller.changeTheme( 154 | setTheme: () => provider.setThemeMode(mode), 155 | center: tapPosition, 156 | ); 157 | } catch (e) { 158 | // Fallback if controller not found (e.g. settings screen) 159 | provider.setThemeMode(mode); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lib/services/sms_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:intl/intl.dart'; 4 | import '../models/transaction.dart'; 5 | 6 | class SmsService { 7 | final SmsQuery _query = SmsQuery(); 8 | 9 | Future> syncMessages(String userId) async { 10 | try { 11 | debugPrint('Querying SMS...'); 12 | final messages = await _query.querySms( 13 | kinds: [SmsQueryKind.inbox], 14 | count: 100, // Limit to last 100 messages for performance 15 | ); 16 | debugPrint('Found ${messages.length} messages'); 17 | 18 | final List transactions = []; 19 | 20 | for (final message in messages) { 21 | // debugPrint('SmsService: Checking message: ${message.body}'); 22 | final transaction = _parseMessage(message, userId); 23 | if (transaction != null) { 24 | debugPrint( 25 | 'Parsed transaction: ${transaction.title} - ${transaction.amount}', 26 | ); 27 | transactions.add(transaction); 28 | } 29 | } 30 | 31 | return transactions; 32 | } catch (e) { 33 | debugPrint('Error reading SMS: $e'); 34 | return []; 35 | } 36 | } 37 | 38 | Transaction? _parseMessage(SmsMessage message, String userId) { 39 | final body = message.body; 40 | if (body == null) return null; 41 | 42 | // debugPrint('Parsing: $body'); 43 | 44 | // Regex for the provided format: 45 | // Rs.50.00 debited A/cXX5150 and credited to Vodafone Idea Rajasthan via UPI Ref No 541214146676 on 26Nov25. 46 | 47 | // 1. Extract Amount 48 | // Matches Rs.50.00, Rs. 50.00, INR 50.00, etc. 49 | final amountMatch = RegExp( 50 | r'(?:Rs\.?|INR)\s*(\d+(?:\.\d+)?)', 51 | caseSensitive: false, 52 | ).firstMatch(body); 53 | if (amountMatch == null) { 54 | // debugPrint('Failed to match amount'); 55 | return null; 56 | } 57 | final amount = double.tryParse(amountMatch.group(1) ?? '') ?? 0.0; 58 | 59 | // 2. Extract Type (debited/credited) 60 | final isExpense = body.toLowerCase().contains('debited'); 61 | if (!isExpense && !body.toLowerCase().contains('credited')) { 62 | // debugPrint('Failed to match type (debited/credited)'); 63 | return null; 64 | } 65 | 66 | // 3. Extract Payee/Payer 67 | String title = 'Unknown Transaction'; 68 | if (isExpense) { 69 | final toMatch = RegExp( 70 | r'credited to (.+?) via', 71 | caseSensitive: false, 72 | ).firstMatch(body); 73 | if (toMatch != null) { 74 | title = toMatch.group(1)?.trim() ?? 'Unknown'; 75 | } else { 76 | // debugPrint('Failed to match payee'); 77 | } 78 | } else { 79 | // Handle credited (income) logic if needed, for now focusing on the provided example 80 | final fromMatch = RegExp( 81 | r'from (.+?) via', 82 | caseSensitive: false, 83 | ).firstMatch(body); 84 | if (fromMatch != null) { 85 | title = fromMatch.group(1)?.trim() ?? 'Unknown'; 86 | } 87 | } 88 | 89 | // 4. Extract Date 90 | DateTime date = message.date ?? DateTime.now(); 91 | final dateMatch = RegExp( 92 | r'on (\d{2}[A-Za-z]{3}\d{2})', 93 | caseSensitive: false, 94 | ).firstMatch(body); 95 | if (dateMatch != null) { 96 | try { 97 | final dateStr = dateMatch.group(1)!; 98 | // Parse format like 26Nov25 99 | final parser = DateFormat('ddMMMyy'); 100 | final parsedDate = parser.parse(dateStr); 101 | 102 | // Combine parsed date with time from message.date if available to preserve order 103 | if (message.date != null) { 104 | date = DateTime( 105 | parsedDate.year, 106 | parsedDate.month, 107 | parsedDate.day, 108 | message.date!.hour, 109 | message.date!.minute, 110 | message.date!.second, 111 | ); 112 | } else { 113 | date = parsedDate; 114 | } 115 | } catch (e) { 116 | debugPrint('Error parsing date: $e'); 117 | } 118 | } 119 | 120 | // 5. Extract Ref No for uniqueness 121 | final refMatch = RegExp( 122 | r'Ref No (\d+)', 123 | caseSensitive: false, 124 | ).firstMatch(body); 125 | final refNo = refMatch?.group(1); 126 | 127 | // 6. Extract Bank Name 128 | // Check for "BankName- Message" at start OR "Message -BankName" at end 129 | // Allow alphanumeric and spaces, relying on the hyphen as separator 130 | final bankMatchStart = RegExp( 131 | r'^([a-zA-Z0-9\s]+)-', 132 | ).firstMatch(body.trim()); 133 | final bankMatchEnd = RegExp(r'-([a-zA-Z0-9\s]+)$').firstMatch(body.trim()); 134 | final bankName = 135 | bankMatchStart?.group(1)?.trim() ?? bankMatchEnd?.group(1)?.trim(); 136 | 137 | // 7. Extract Account Last 4 (e.g., A/cXX5150) 138 | final accMatch = RegExp(r'A/cXX(\d+)').firstMatch(body); 139 | final accLast4 = accMatch?.group(1); 140 | 141 | // Use Ref No as ID if available, otherwise fallback to hash 142 | final id = refNo ?? 'sms_${message.date?.millisecondsSinceEpoch}_$amount'; 143 | 144 | return Transaction( 145 | id: id, 146 | title: title, 147 | amount: amount, 148 | date: date, 149 | isExpense: isExpense, 150 | category: isExpense ? 'Utilities' : 'Income', 151 | accountId: userId, 152 | userId: userId, 153 | smsBody: body, 154 | referenceNumber: refNo, 155 | bankName: bankName, 156 | accountLast4: accLast4, 157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/widgets/theme_reveal.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | import 'dart:math' show sqrt, max; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter/rendering.dart'; 5 | 6 | class ThemeReveal extends StatefulWidget { 7 | final Widget child; 8 | final Animation animation; 9 | final ui.Image? previousImage; 10 | final Offset center; 11 | 12 | const ThemeReveal({ 13 | super.key, 14 | required this.child, 15 | required this.animation, 16 | this.previousImage, 17 | this.center = Offset.zero, 18 | }); 19 | 20 | @override 21 | State createState() => _ThemeRevealState(); 22 | } 23 | 24 | class _ThemeRevealState extends State { 25 | @override 26 | Widget build(BuildContext context) { 27 | if (widget.previousImage == null || widget.animation.value == 1.0) { 28 | return widget.child; 29 | } 30 | 31 | return Stack( 32 | fit: StackFit.expand, 33 | children: [ 34 | // Background: The "old" screenshot 35 | CustomPaint( 36 | painter: _ImagePainter(widget.previousImage!), 37 | size: Size.infinite, 38 | ), 39 | // Foreground: The "new" theme content, clipped 40 | ClipPath( 41 | clipper: _CircularRevealClipper( 42 | center: widget.center, 43 | fraction: widget.animation.value, 44 | ), 45 | child: widget.child, 46 | ), 47 | ], 48 | ); 49 | } 50 | } 51 | 52 | class _ImagePainter extends CustomPainter { 53 | final ui.Image image; 54 | 55 | _ImagePainter(this.image); 56 | 57 | @override 58 | void paint(Canvas canvas, Size size) { 59 | final src = Rect.fromLTWH( 60 | 0, 61 | 0, 62 | image.width.toDouble(), 63 | image.height.toDouble(), 64 | ); 65 | final dst = Rect.fromLTWH(0, 0, size.width, size.height); 66 | canvas.drawImageRect(image, src, dst, Paint()); 67 | } 68 | 69 | @override 70 | bool shouldRepaint(covariant _ImagePainter oldDelegate) { 71 | return oldDelegate.image != image; 72 | } 73 | } 74 | 75 | class _CircularRevealClipper extends CustomClipper { 76 | final Offset center; 77 | final double fraction; 78 | 79 | _CircularRevealClipper({required this.center, required this.fraction}); 80 | 81 | @override 82 | Path getClip(Size size) { 83 | final Path path = Path(); 84 | // Calculate max radius to cover the screen from the center point 85 | final double maxRadius = calcMaxRadius(size, center); 86 | 87 | path.addOval(Rect.fromCircle(center: center, radius: maxRadius * fraction)); 88 | return path; 89 | } 90 | 91 | static double calcMaxRadius(Size size, Offset center) { 92 | final double w = max(center.dx, size.width - center.dx); 93 | final double h = max(center.dy, size.height - center.dy); 94 | return sqrt(w * w + h * h); 95 | } 96 | 97 | @override 98 | bool shouldReclip(covariant _CircularRevealClipper oldClipper) { 99 | return oldClipper.fraction != fraction || oldClipper.center != center; 100 | } 101 | } 102 | 103 | /// Controller to wrap the app/screen and manage the state 104 | class ThemeRevealController extends StatefulWidget { 105 | final Widget child; 106 | 107 | const ThemeRevealController({super.key, required this.child}); 108 | 109 | static ThemeRevealControllerState of(BuildContext context) { 110 | return context.findAncestorStateOfType()!; 111 | } 112 | 113 | @override 114 | State createState() => ThemeRevealControllerState(); 115 | } 116 | 117 | class ThemeRevealControllerState extends State 118 | with SingleTickerProviderStateMixin { 119 | late AnimationController _controller; 120 | ui.Image? _lastScreenshot; 121 | Offset _center = Offset.zero; 122 | final GlobalKey _globalKey = GlobalKey(); 123 | 124 | @override 125 | void initState() { 126 | super.initState(); 127 | _controller = AnimationController( 128 | duration: const Duration(milliseconds: 600), 129 | vsync: this, 130 | ); 131 | } 132 | 133 | @override 134 | void dispose() { 135 | _controller.dispose(); 136 | super.dispose(); 137 | } 138 | 139 | Future changeTheme({ 140 | required VoidCallback setTheme, 141 | required Offset center, 142 | }) async { 143 | // 1. Capture screenshot of current state 144 | final boundary = 145 | _globalKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; 146 | if (boundary != null) { 147 | // Must ignore the pixel ratio for the simple painter or handle scaling. 148 | // Usually matching the logical size is easiest if we draw it 1:1. 149 | // But findRenderObject usually acts in physical pixels. 150 | // Let's rely on standard capture. 151 | final image = await boundary.toImage( 152 | pixelRatio: View.of(context).devicePixelRatio, 153 | ); 154 | setState(() { 155 | _lastScreenshot = image; 156 | _center = center; 157 | }); 158 | } 159 | 160 | // 2. Update the theme (callback to provider) 161 | setTheme(); 162 | 163 | // 3. Reset animation and play 164 | _controller.value = 0.0; 165 | _controller.forward().then((_) { 166 | setState(() { 167 | _lastScreenshot = null; // Cleanup memory 168 | }); 169 | }); 170 | } 171 | 172 | @override 173 | Widget build(BuildContext context) { 174 | return RepaintBoundary( 175 | key: _globalKey, 176 | child: AnimatedBuilder( 177 | animation: _controller, 178 | builder: (context, child) { 179 | return ThemeReveal( 180 | animation: _controller, 181 | previousImage: _lastScreenshot, 182 | center: _center, 183 | child: widget.child, 184 | ); 185 | }, 186 | ), 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /lib/widgets/analytics_chart.dart: -------------------------------------------------------------------------------- 1 | import 'package:fl_chart/fl_chart.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:flutter_animate/flutter_animate.dart'; 5 | import '../providers/money_provider.dart'; 6 | import '../utils/app_theme.dart'; 7 | 8 | class AnalyticsChart extends StatefulWidget { 9 | const AnalyticsChart({super.key}); 10 | 11 | @override 12 | State createState() => _AnalyticsChartState(); 13 | } 14 | 15 | class _AnalyticsChartState extends State { 16 | int _touchedIndex = -1; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final provider = Provider.of(context); 21 | final transactions = provider.transactions 22 | .where((t) => t.isExpense) 23 | .toList(); 24 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 25 | final isLight = provider.appThemeMode == AppThemeMode.light; 26 | 27 | if (transactions.isEmpty) { 28 | return Center( 29 | child: Text( 30 | 'No expenses to show', 31 | style: Theme.of(context).textTheme.bodyLarge?.copyWith( 32 | color: isLight ? Colors.black54 : Colors.white54, 33 | ), 34 | ), 35 | ); 36 | } 37 | 38 | // Group expenses by category 39 | final Map categoryTotals = {}; 40 | for (var t in transactions) { 41 | categoryTotals[t.category] = (categoryTotals[t.category] ?? 0) + t.amount; 42 | } 43 | 44 | final List sections = []; 45 | int index = 0; 46 | categoryTotals.forEach((category, amount) { 47 | final isTouched = index == _touchedIndex; 48 | final fontSize = isTouched ? 18.0 : 14.0; 49 | final radius = isTouched ? 110.0 : 100.0; 50 | 51 | final color = isAmoled 52 | ? (index == 0 53 | ? Colors.white 54 | : Colors.white.withOpacity( 55 | 1.0 - (index * 0.15).clamp(0.0, 0.7), 56 | )) 57 | : (isLight 58 | ? (index == 0 59 | ? Colors.black 60 | : Colors.black.withOpacity( 61 | 1.0 - (index * 0.15).clamp(0.0, 0.7), 62 | )) 63 | : Colors.primaries[index % Colors.primaries.length]); 64 | 65 | sections.add( 66 | PieChartSectionData( 67 | color: color, 68 | value: amount, 69 | title: 70 | '${((amount / provider.totalExpense) * 100).toStringAsFixed(0)}%', 71 | radius: radius, 72 | titleStyle: TextStyle( 73 | fontSize: fontSize, 74 | fontWeight: FontWeight.bold, 75 | color: Colors.white, 76 | shadows: [const Shadow(color: Colors.black, blurRadius: 2)], 77 | ), 78 | badgeWidget: _Badge(category, size: 40, borderColor: color), 79 | badgePositionPercentageOffset: .98, 80 | ), 81 | ); 82 | index++; 83 | }); 84 | 85 | return AspectRatio( 86 | aspectRatio: 1.3, 87 | child: TweenAnimationBuilder( 88 | tween: Tween(begin: 0.0, end: 1.0), 89 | duration: const Duration(milliseconds: 1500), 90 | curve: Curves.easeOutCirc, 91 | builder: (context, value, child) { 92 | return ShaderMask( 93 | shaderCallback: (rect) { 94 | return SweepGradient( 95 | startAngle: 0.0, 96 | endAngle: 3.14 * 2, 97 | stops: [value, value], 98 | colors: const [Colors.white, Colors.transparent], 99 | transform: GradientRotation(-3.14 / 2), // Start from top 100 | ).createShader(rect); 101 | }, 102 | child: child, 103 | ); 104 | }, 105 | child: PieChart( 106 | PieChartData( 107 | pieTouchData: PieTouchData( 108 | touchCallback: (FlTouchEvent event, pieTouchResponse) { 109 | setState(() { 110 | if (!event.isInterestedForInteractions || 111 | pieTouchResponse == null || 112 | pieTouchResponse.touchedSection == null) { 113 | _touchedIndex = -1; 114 | return; 115 | } 116 | _touchedIndex = 117 | pieTouchResponse.touchedSection!.touchedSectionIndex; 118 | }); 119 | }, 120 | ), 121 | borderData: FlBorderData(show: false), 122 | sectionsSpace: 2, 123 | centerSpaceRadius: 40, 124 | sections: sections, 125 | ), 126 | ), 127 | ), 128 | ) 129 | .animate() 130 | .scale(duration: 800.ms, curve: Curves.easeOutBack) 131 | .fadeIn(duration: 500.ms); 132 | } 133 | } 134 | 135 | class _Badge extends StatelessWidget { 136 | const _Badge(this.text, {required this.size, required this.borderColor}); 137 | 138 | final String text; 139 | final double size; 140 | final Color borderColor; 141 | 142 | @override 143 | Widget build(BuildContext context) { 144 | return AnimatedContainer( 145 | duration: PieChart.defaultDuration, 146 | width: size, 147 | height: size, 148 | decoration: BoxDecoration( 149 | color: Colors.white, 150 | shape: BoxShape.circle, 151 | border: Border.all(color: borderColor, width: 2), 152 | boxShadow: [ 153 | BoxShadow( 154 | color: Colors.black.withOpacity(0.2), 155 | offset: const Offset(3, 3), 156 | blurRadius: 3, 157 | ), 158 | ], 159 | ), 160 | padding: const EdgeInsets.all(4), 161 | child: Center( 162 | child: Text( 163 | text[0], 164 | style: TextStyle( 165 | fontSize: size * 0.5, 166 | fontWeight: FontWeight.bold, 167 | color: Colors.black, 168 | ), 169 | ), 170 | ), 171 | ); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /project_analysis.txt: -------------------------------------------------------------------------------- 1 | Analyzing PennyWise... 2 | 3 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:958:21 • avoid_types_as_parameter_names 4 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:964:21 • avoid_types_as_parameter_names 5 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1120:21 • avoid_types_as_parameter_names 6 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1134:21 • avoid_types_as_parameter_names 7 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1148:21 • avoid_types_as_parameter_names 8 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1179:5 • avoid_print 9 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1180:5 • avoid_print 10 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1181:5 • avoid_print 11 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1216:5 • avoid_print 12 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1219:5 • avoid_print 13 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1244:69 • avoid_types_as_parameter_names 14 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1252:19 • avoid_types_as_parameter_names 15 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1258:19 • avoid_types_as_parameter_names 16 | info • Statements in an if should be enclosed in a block • lib/providers/money_provider.dart:1712:7 • curly_braces_in_flow_control_structures 17 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/category_management_screen.dart:688:56 • deprecated_member_use 18 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/category_management_screen.dart:688:71 • deprecated_member_use 19 | info • The import of 'package:flutter/services.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' • lib/screens/lock_screen.dart:2:8 • unnecessary_import 20 | info • Don't use 'BuildContext's across async gaps • lib/screens/settings_screen.dart:161:32 • use_build_context_synchronously 21 | warning • The member 'notifyListeners' can only be used within 'package:flutter/src/foundation/change_notifier.dart' or a test • lib/screens/settings_screen.dart:288:20 • invalid_use_of_visible_for_testing_member 22 | warning • The member 'notifyListeners' can only be used within instance members of subclasses of 'ChangeNotifier' • lib/screens/settings_screen.dart:288:20 • invalid_use_of_protected_member 23 | info • Don't use 'BuildContext's across async gaps • lib/screens/settings_screen.dart:290:32 • use_build_context_synchronously 24 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/settings_screen.dart:917:13 • deprecated_member_use 25 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/transaction_detail_screen.dart:469:29 • deprecated_member_use 26 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/transaction_detail_screen.dart:740:33 • deprecated_member_use 27 | info • Don't use 'BuildContext's across async gaps • lib/screens/transaction_detail_screen.dart:1628:9 • use_build_context_synchronously 28 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1968:30 • use_build_context_synchronously 29 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1985:7 • use_build_context_synchronously 30 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2490:26 • use_build_context_synchronously 31 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2491:26 • use_build_context_synchronously 32 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/services/google_drive_service.dart:590:53 • deprecated_member_use 33 | info • Statements in a while should be enclosed in a block • lib/services/update_service.dart:123:38 • curly_braces_in_flow_control_structures 34 | info • Statements in a while should be enclosed in a block • lib/services/update_service.dart:124:39 • curly_braces_in_flow_control_structures 35 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:373:42 • use_build_context_synchronously 36 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1006:46 • use_build_context_synchronously 37 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1015:44 • use_build_context_synchronously 38 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1137:40 • use_build_context_synchronously 39 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1144:40 • use_build_context_synchronously 40 | info • Unnecessary 'const' keyword • lib/widgets/card_designs.dart:5329:30 • unnecessary_const 41 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:245:9 • avoid_print 42 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:246:9 • avoid_print 43 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:247:9 • avoid_print 44 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:248:9 • avoid_print 45 | 46 | 42 issues found. (ran in 11.6s) 47 | -------------------------------------------------------------------------------- /lib/screens/lock_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import '../services/biometric_service.dart'; 4 | import '../utils/app_theme.dart'; 5 | import 'package:vibration/vibration.dart'; 6 | 7 | class LockScreen extends StatefulWidget { 8 | final Widget child; 9 | final bool isEnabled; 10 | 11 | const LockScreen({super.key, required this.child, required this.isEnabled}); 12 | 13 | @override 14 | State createState() => _LockScreenState(); 15 | } 16 | 17 | class _LockScreenState extends State with WidgetsBindingObserver { 18 | final BiometricService _biometricService = BiometricService(); 19 | bool _isLocked = true; 20 | bool _isAuthenticating = false; 21 | 22 | @override 23 | void initState() { 24 | super.initState(); 25 | WidgetsBinding.instance.addObserver(this); 26 | if (widget.isEnabled) { 27 | _authenticate(); 28 | } else { 29 | _isLocked = false; 30 | } 31 | } 32 | 33 | @override 34 | void dispose() { 35 | WidgetsBinding.instance.removeObserver(this); 36 | super.dispose(); 37 | } 38 | 39 | @override 40 | void didChangeAppLifecycleState(AppLifecycleState state) { 41 | if (!widget.isEnabled) return; 42 | 43 | if (state == AppLifecycleState.paused) { 44 | // App went to background - lock it 45 | setState(() { 46 | _isLocked = true; 47 | }); 48 | } else if (state == AppLifecycleState.resumed && _isLocked) { 49 | // App came back - authenticate 50 | _authenticate(); 51 | } 52 | } 53 | 54 | @override 55 | void didUpdateWidget(LockScreen oldWidget) { 56 | super.didUpdateWidget(oldWidget); 57 | // If lock was disabled, unlock 58 | if (!widget.isEnabled && _isLocked) { 59 | setState(() { 60 | _isLocked = false; 61 | }); 62 | } 63 | // If lock was just enabled (e.g., settings loaded after cold start), lock and authenticate 64 | if (widget.isEnabled && !oldWidget.isEnabled) { 65 | setState(() { 66 | _isLocked = true; 67 | }); 68 | _authenticate(); 69 | } 70 | } 71 | 72 | Future _authenticate() async { 73 | if (_isAuthenticating) return; 74 | 75 | // Provide immediate haptic feedback for button press 76 | Vibration.vibrate(duration: 50, amplitude: 128); 77 | 78 | setState(() { 79 | _isAuthenticating = true; 80 | }); 81 | 82 | try { 83 | // Use combined fast check from BiometricService 84 | final isAvailable = await _biometricService.isAvailable(); 85 | 86 | if (!isAvailable) { 87 | // No biometrics available, unlock anyway 88 | setState(() { 89 | _isLocked = false; 90 | _isAuthenticating = false; 91 | }); 92 | return; 93 | } 94 | 95 | final didAuthenticate = await _biometricService.authenticate( 96 | localizedReason: 'Authenticate to access PennyWise', 97 | biometricOnly: false, // Allow PIN/pattern as fallback 98 | ); 99 | 100 | if (didAuthenticate) { 101 | setState(() { 102 | _isLocked = false; 103 | }); 104 | } 105 | } catch (e) { 106 | debugPrint('Auth error: $e'); 107 | } finally { 108 | if (mounted) { 109 | setState(() { 110 | _isAuthenticating = false; 111 | }); 112 | } 113 | } 114 | } 115 | 116 | @override 117 | Widget build(BuildContext context) { 118 | if (!widget.isEnabled || !_isLocked) { 119 | return widget.child; 120 | } 121 | 122 | return Scaffold( 123 | backgroundColor: AppTheme.background, 124 | body: SafeArea( 125 | child: Center( 126 | child: Column( 127 | mainAxisAlignment: MainAxisAlignment.center, 128 | children: [ 129 | // Lock icon 130 | Container( 131 | padding: const EdgeInsets.all(24), 132 | decoration: BoxDecoration( 133 | color: AppTheme.primary.withOpacity(0.1), 134 | shape: BoxShape.circle, 135 | ), 136 | child: Icon( 137 | Icons.lock_outline, 138 | size: 64, 139 | color: AppTheme.primary, 140 | ), 141 | ), 142 | const SizedBox(height: 32), 143 | 144 | // App name 145 | const Text( 146 | 'PennyWise', 147 | style: TextStyle( 148 | fontSize: 28, 149 | fontWeight: FontWeight.bold, 150 | color: Colors.white, 151 | ), 152 | ), 153 | const SizedBox(height: 8), 154 | 155 | Text( 156 | 'Locked', 157 | style: TextStyle( 158 | fontSize: 16, 159 | color: Colors.white.withOpacity(0.6), 160 | ), 161 | ), 162 | const SizedBox(height: 48), 163 | 164 | // Authenticate button 165 | if (_isAuthenticating) 166 | const CircularProgressIndicator(color: AppTheme.primary) 167 | else 168 | GestureDetector( 169 | onTap: _authenticate, 170 | child: Container( 171 | padding: const EdgeInsets.symmetric( 172 | horizontal: 32, 173 | vertical: 16, 174 | ), 175 | decoration: BoxDecoration( 176 | color: AppTheme.primary, 177 | borderRadius: BorderRadius.circular(30), 178 | boxShadow: [ 179 | BoxShadow( 180 | color: AppTheme.primary.withOpacity(0.3), 181 | blurRadius: 12, 182 | offset: const Offset(0, 6), 183 | ), 184 | ], 185 | ), 186 | child: Row( 187 | mainAxisSize: MainAxisSize.min, 188 | children: [ 189 | const Icon( 190 | Icons.fingerprint, 191 | color: Colors.white, 192 | size: 24, 193 | ), 194 | const SizedBox(width: 12), 195 | const Text( 196 | 'Unlock', 197 | style: TextStyle( 198 | color: Colors.white, 199 | fontSize: 18, 200 | fontWeight: FontWeight.w600, 201 | ), 202 | ), 203 | ], 204 | ), 205 | ), 206 | ), 207 | 208 | const SizedBox(height: 24), 209 | 210 | Text( 211 | 'Use fingerprint, face, or device PIN', 212 | style: TextStyle( 213 | fontSize: 12, 214 | color: Colors.white.withOpacity(0.4), 215 | ), 216 | ), 217 | ], 218 | ), 219 | ), 220 | ), 221 | ); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /lib/widgets/category_search_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:flutter_animate/flutter_animate.dart'; 4 | import '../models/category.dart'; 5 | import '../providers/money_provider.dart'; 6 | import '../utils/app_theme.dart'; 7 | 8 | class CategorySearchSheet extends StatefulWidget { 9 | final Function(Category) onCategorySelected; 10 | final bool isExpense; 11 | 12 | const CategorySearchSheet({ 13 | super.key, 14 | required this.onCategorySelected, 15 | required this.isExpense, 16 | }); 17 | 18 | @override 19 | State createState() => _CategorySearchSheetState(); 20 | } 21 | 22 | class _CategorySearchSheetState extends State { 23 | final TextEditingController _searchController = TextEditingController(); 24 | String _searchQuery = ''; 25 | 26 | @override 27 | void dispose() { 28 | _searchController.dispose(); 29 | super.dispose(); 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | final provider = Provider.of(context); 35 | // Get all categories including subcategories 36 | final allCategories = provider.categories; 37 | 38 | final filteredCategories = allCategories.where((cat) { 39 | final matchesQuery = cat.name.toLowerCase().contains( 40 | _searchQuery.toLowerCase(), 41 | ); 42 | return matchesQuery; 43 | }).toList(); 44 | 45 | return Container( 46 | height: MediaQuery.of(context).size.height * 0.7, 47 | decoration: const BoxDecoration( 48 | color: Color(0xFF0F111A), 49 | borderRadius: BorderRadius.vertical(top: Radius.circular(24)), 50 | ), 51 | child: Column( 52 | children: [ 53 | // Handle 54 | const SizedBox(height: 12), 55 | Container( 56 | width: 40, 57 | height: 4, 58 | decoration: BoxDecoration( 59 | color: Colors.white.withOpacity(0.1), 60 | borderRadius: BorderRadius.circular(2), 61 | ), 62 | ), 63 | const SizedBox(height: 20), 64 | 65 | // Search Field 66 | Padding( 67 | padding: const EdgeInsets.symmetric(horizontal: 16), 68 | child: TextField( 69 | controller: _searchController, 70 | autofocus: true, 71 | style: const TextStyle(color: Colors.white), 72 | decoration: InputDecoration( 73 | hintText: 'Search categories...', 74 | hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)), 75 | prefixIcon: const Icon(Icons.search, color: AppTheme.primary), 76 | suffixIcon: _searchQuery.isNotEmpty 77 | ? IconButton( 78 | icon: const Icon(Icons.clear, color: Colors.white54), 79 | onPressed: () { 80 | setState(() { 81 | _searchController.clear(); 82 | _searchQuery = ''; 83 | }); 84 | }, 85 | ) 86 | : null, 87 | filled: true, 88 | fillColor: AppTheme.surface, 89 | border: OutlineInputBorder( 90 | borderRadius: BorderRadius.circular(16), 91 | borderSide: BorderSide.none, 92 | ), 93 | contentPadding: const EdgeInsets.symmetric(vertical: 16), 94 | ), 95 | onChanged: (value) { 96 | setState(() { 97 | _searchQuery = value; 98 | }); 99 | }, 100 | ), 101 | ), 102 | 103 | const SizedBox(height: 16), 104 | 105 | // Results 106 | Expanded( 107 | child: filteredCategories.isEmpty 108 | ? _buildEmptyState() 109 | : ListView.builder( 110 | padding: const EdgeInsets.symmetric( 111 | horizontal: 16, 112 | vertical: 8, 113 | ), 114 | itemCount: filteredCategories.length, 115 | itemBuilder: (context, index) { 116 | final category = filteredCategories[index]; 117 | return _buildCategoryItem(category); 118 | }, 119 | ), 120 | ), 121 | ], 122 | ), 123 | ); 124 | } 125 | 126 | Widget _buildEmptyState() { 127 | return Center( 128 | child: Column( 129 | mainAxisAlignment: MainAxisAlignment.center, 130 | children: [ 131 | Icon( 132 | Icons.category_outlined, 133 | size: 64, 134 | color: Colors.white.withOpacity(0.1), 135 | ), 136 | const SizedBox(height: 16), 137 | Text( 138 | 'No categories found', 139 | style: TextStyle( 140 | color: Colors.white.withOpacity(0.5), 141 | fontSize: 16, 142 | ), 143 | ), 144 | ], 145 | ), 146 | ).animate().fadeIn(); 147 | } 148 | 149 | Widget _buildCategoryItem(Category category) { 150 | return GestureDetector( 151 | onTap: () { 152 | widget.onCategorySelected(category); 153 | Navigator.pop(context); 154 | }, 155 | child: Container( 156 | margin: const EdgeInsets.only(bottom: 8), 157 | padding: const EdgeInsets.all(12), 158 | decoration: BoxDecoration( 159 | color: AppTheme.surface.withOpacity(0.5), 160 | borderRadius: BorderRadius.circular(16), 161 | border: Border.all(color: Colors.white.withOpacity(0.05)), 162 | ), 163 | child: Row( 164 | children: [ 165 | Container( 166 | padding: const EdgeInsets.all(10), 167 | decoration: BoxDecoration( 168 | color: category.color.withOpacity(0.15), 169 | shape: BoxShape.circle, 170 | ), 171 | child: Icon(category.icon, color: category.color, size: 20), 172 | ), 173 | const SizedBox(width: 16), 174 | Expanded( 175 | child: Column( 176 | crossAxisAlignment: CrossAxisAlignment.start, 177 | children: [ 178 | Text( 179 | category.name, 180 | style: const TextStyle( 181 | color: Colors.white, 182 | fontSize: 16, 183 | fontWeight: FontWeight.w600, 184 | ), 185 | ), 186 | if (category.parentId != null) 187 | Text( 188 | 'Subcategory', 189 | style: TextStyle( 190 | color: Colors.white.withOpacity(0.4), 191 | fontSize: 12, 192 | ), 193 | ), 194 | ], 195 | ), 196 | ), 197 | Icon(Icons.chevron_right, color: Colors.white.withOpacity(0.2)), 198 | ], 199 | ), 200 | ), 201 | ).animate().fadeIn(delay: 50.ms).slideX(begin: 0.05, end: 0); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /lib/screens/analytics_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../widgets/analytics_chart.dart'; 3 | import '../widgets/spending_chart.dart'; 4 | import '../widgets/spending_heatmap.dart'; 5 | import '../widgets/period_comparison.dart'; 6 | import '../utils/app_theme.dart'; 7 | 8 | import '../providers/money_provider.dart'; 9 | import 'package:provider/provider.dart'; 10 | 11 | class AnalyticsScreen extends StatelessWidget { 12 | const AnalyticsScreen({super.key}); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final provider = Provider.of(context); 17 | 18 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 19 | final isLight = provider.appThemeMode == AppThemeMode.light; 20 | 21 | return Scaffold( 22 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 23 | appBar: AppBar( 24 | backgroundColor: Colors.transparent, 25 | elevation: 0, 26 | automaticallyImplyLeading: false, 27 | title: Text( 28 | 'Analytics', 29 | style: TextStyle( 30 | color: Theme.of(context).textTheme.titleLarge?.color, 31 | ), 32 | ), 33 | ), 34 | body: SingleChildScrollView( 35 | padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), 36 | child: Column( 37 | crossAxisAlignment: CrossAxisAlignment.start, 38 | children: [ 39 | // Spending Heatmap Section 40 | Row( 41 | children: [ 42 | Container( 43 | padding: const EdgeInsets.all(8), 44 | decoration: BoxDecoration( 45 | color: isAmoled || isLight 46 | ? Colors.transparent 47 | : Colors.orange.withOpacity(0.2), 48 | borderRadius: BorderRadius.circular(10), 49 | border: isLight 50 | ? Border.all(color: Colors.black) 51 | : (isAmoled ? Border.all(color: Colors.white) : null), 52 | ), 53 | child: Icon( 54 | Icons.calendar_month, 55 | color: isLight 56 | ? Colors.black 57 | : (isAmoled ? Colors.white : Colors.orange), 58 | size: 20, 59 | ), 60 | ), 61 | const SizedBox(width: 12), 62 | Text( 63 | 'Spending Heatmap', 64 | style: Theme.of(context).textTheme.titleLarge?.copyWith( 65 | fontWeight: FontWeight.bold, 66 | color: isLight ? Colors.black : null, 67 | ), 68 | ), 69 | ], 70 | ), 71 | const SizedBox(height: 16), 72 | const SpendingHeatmap(), 73 | 74 | const SizedBox(height: 32), 75 | 76 | // Weekly/Monthly Comparison Section 77 | Row( 78 | children: [ 79 | Container( 80 | padding: const EdgeInsets.all(8), 81 | decoration: BoxDecoration( 82 | color: isAmoled || isLight 83 | ? Colors.transparent 84 | : AppTheme.primary.withOpacity(0.2), 85 | borderRadius: BorderRadius.circular(10), 86 | border: isLight 87 | ? Border.all(color: Colors.black) 88 | : (isAmoled ? Border.all(color: Colors.white) : null), 89 | ), 90 | child: Icon( 91 | Icons.compare_arrows, 92 | color: isLight 93 | ? Colors.black 94 | : (isAmoled ? Colors.white : AppTheme.primary), 95 | size: 20, 96 | ), 97 | ), 98 | const SizedBox(width: 12), 99 | Text( 100 | 'Period Comparison', 101 | style: Theme.of(context).textTheme.titleLarge?.copyWith( 102 | fontWeight: FontWeight.bold, 103 | color: isLight ? Colors.black : null, 104 | ), 105 | ), 106 | ], 107 | ), 108 | const SizedBox(height: 16), 109 | const PeriodComparison(), 110 | 111 | const SizedBox(height: 32), 112 | 113 | // Expense Breakdown Section 114 | Row( 115 | children: [ 116 | Container( 117 | padding: const EdgeInsets.all(8), 118 | decoration: BoxDecoration( 119 | color: isAmoled || isLight 120 | ? Colors.transparent 121 | : Colors.purple.withOpacity(0.2), 122 | borderRadius: BorderRadius.circular(10), 123 | border: isLight 124 | ? Border.all(color: Colors.black) 125 | : (isAmoled ? Border.all(color: Colors.white) : null), 126 | ), 127 | child: Icon( 128 | Icons.pie_chart, 129 | color: isLight 130 | ? Colors.black 131 | : (isAmoled ? Colors.white : Colors.purple), 132 | size: 20, 133 | ), 134 | ), 135 | const SizedBox(width: 12), 136 | Text( 137 | 'Expense Breakdown', 138 | style: Theme.of(context).textTheme.titleLarge?.copyWith( 139 | fontWeight: FontWeight.bold, 140 | color: isLight ? Colors.black : null, 141 | ), 142 | ), 143 | ], 144 | ), 145 | const SizedBox(height: 24), 146 | const AnalyticsChart(), 147 | 148 | const SizedBox(height: 32), 149 | 150 | // Spending Trends Section 151 | Row( 152 | children: [ 153 | Container( 154 | padding: const EdgeInsets.all(8), 155 | decoration: BoxDecoration( 156 | color: isAmoled || isLight 157 | ? Colors.transparent 158 | : AppTheme.expense.withOpacity(0.2), 159 | borderRadius: BorderRadius.circular(10), 160 | border: isLight 161 | ? Border.all(color: Colors.black) 162 | : (isAmoled ? Border.all(color: Colors.white) : null), 163 | ), 164 | child: Icon( 165 | Icons.show_chart, 166 | color: isLight 167 | ? Colors.black 168 | : (isAmoled ? Colors.white : AppTheme.expense), 169 | size: 20, 170 | ), 171 | ), 172 | const SizedBox(width: 12), 173 | Text( 174 | 'Spending Trends (Last 7 Days)', 175 | style: Theme.of(context).textTheme.titleLarge?.copyWith( 176 | fontWeight: FontWeight.bold, 177 | color: isLight ? Colors.black : Colors.white, 178 | ), 179 | ), 180 | ], 181 | ), 182 | const SizedBox(height: 16), 183 | const SpendingChart(), 184 | const SizedBox(height: 32), 185 | ], 186 | ), 187 | ), 188 | ); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /lib/widgets/animated_digit_text.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A widget that displays a number with a smooth scrolling animation 4 | class AnimatedDigitText extends StatefulWidget { 5 | final String value; 6 | final TextStyle? style; 7 | final Duration duration; 8 | final Curve curve; 9 | 10 | const AnimatedDigitText({ 11 | super.key, 12 | required this.value, 13 | this.style, 14 | this.duration = const Duration(milliseconds: 600), 15 | this.curve = Curves.easeOutCubic, 16 | }); 17 | 18 | @override 19 | State createState() => _AnimatedDigitTextState(); 20 | } 21 | 22 | class _AnimatedDigitTextState extends State 23 | with SingleTickerProviderStateMixin { 24 | late AnimationController _controller; 25 | late Animation _animation; 26 | String _oldValue = ''; 27 | String _currentValue = ''; 28 | bool _isFirstBuild = true; 29 | 30 | @override 31 | void initState() { 32 | super.initState(); 33 | _currentValue = widget.value; 34 | _oldValue = widget.value; 35 | 36 | _controller = AnimationController( 37 | duration: widget.duration, 38 | vsync: this, 39 | ); 40 | _animation = CurvedAnimation( 41 | parent: _controller, 42 | curve: widget.curve, 43 | ); 44 | } 45 | 46 | @override 47 | void didUpdateWidget(AnimatedDigitText oldWidget) { 48 | super.didUpdateWidget(oldWidget); 49 | if (oldWidget.value != widget.value) { 50 | _oldValue = _currentValue; 51 | _currentValue = widget.value; 52 | _controller.reset(); 53 | _controller.forward(); 54 | } 55 | } 56 | 57 | @override 58 | void dispose() { 59 | _controller.dispose(); 60 | super.dispose(); 61 | } 62 | 63 | @override 64 | Widget build(BuildContext context) { 65 | final textStyle = widget.style ?? const TextStyle(fontSize: 34); 66 | 67 | // Measure dimensions 68 | final textPainter = TextPainter( 69 | text: TextSpan(text: '0', style: textStyle), 70 | textDirection: TextDirection.ltr, 71 | )..layout(); 72 | final digitWidth = textPainter.width + 2; 73 | final digitHeight = textPainter.height; 74 | 75 | // First build - no animation 76 | if (_isFirstBuild) { 77 | _isFirstBuild = false; 78 | return _buildStaticText(_currentValue, textStyle, digitWidth, digitHeight); 79 | } 80 | 81 | return AnimatedBuilder( 82 | animation: _animation, 83 | builder: (context, child) { 84 | final progress = _animation.value; 85 | 86 | // Animation complete - show static 87 | if (progress >= 1.0 || !_controller.isAnimating) { 88 | return _buildStaticText(_currentValue, textStyle, digitWidth, digitHeight); 89 | } 90 | 91 | // During animation 92 | return _buildAnimatedText( 93 | _oldValue, 94 | _currentValue, 95 | progress, 96 | textStyle, 97 | digitWidth, 98 | digitHeight, 99 | ); 100 | }, 101 | ); 102 | } 103 | 104 | Widget _buildStaticText( 105 | String value, 106 | TextStyle style, 107 | double digitWidth, 108 | double digitHeight, 109 | ) { 110 | return Row( 111 | mainAxisSize: MainAxisSize.min, 112 | crossAxisAlignment: CrossAxisAlignment.center, 113 | children: value.split('').map((char) { 114 | final isDigit = RegExp(r'[0-9]').hasMatch(char); 115 | 116 | if (isDigit) { 117 | return SizedBox( 118 | width: digitWidth, 119 | height: digitHeight, 120 | child: Center(child: Text(char, style: style)), 121 | ); 122 | } else { 123 | final charPainter = TextPainter( 124 | text: TextSpan(text: char, style: style), 125 | textDirection: TextDirection.ltr, 126 | )..layout(); 127 | 128 | return SizedBox( 129 | width: charPainter.width + 1, 130 | height: digitHeight, 131 | child: Center(child: Text(char, style: style)), 132 | ); 133 | } 134 | }).toList(), 135 | ); 136 | } 137 | 138 | Widget _buildAnimatedText( 139 | String oldValue, 140 | String newValue, 141 | double progress, 142 | TextStyle style, 143 | double digitWidth, 144 | double digitHeight, 145 | ) { 146 | // Extract only digits from both values 147 | final oldDigits = oldValue.replaceAll(RegExp(r'[^0-9]'), ''); 148 | final newDigits = newValue.replaceAll(RegExp(r'[^0-9]'), ''); 149 | 150 | // Pad to same length 151 | final maxDigitLen = oldDigits.length > newDigits.length 152 | ? oldDigits.length 153 | : newDigits.length; 154 | final paddedOldDigits = oldDigits.padLeft(maxDigitLen, '0'); 155 | 156 | // Build the new value with animations 157 | int digitIndex = 0; 158 | 159 | return Row( 160 | mainAxisSize: MainAxisSize.min, 161 | crossAxisAlignment: CrossAxisAlignment.center, 162 | children: newValue.split('').map((char) { 163 | final isDigit = RegExp(r'[0-9]').hasMatch(char); 164 | 165 | if (isDigit) { 166 | // Get corresponding old and new digit 167 | final newDigitPosition = newDigits.length - 1 - (newDigits.length - 1 - digitIndex); 168 | final oldDigitIdx = paddedOldDigits.length - (newDigits.length - newDigitPosition); 169 | 170 | final newDigit = int.parse(char); 171 | final oldDigit = oldDigitIdx >= 0 && oldDigitIdx < paddedOldDigits.length 172 | ? int.parse(paddedOldDigits[oldDigitIdx]) 173 | : 0; 174 | 175 | digitIndex++; 176 | 177 | // Animate if digits are different 178 | if (oldDigit != newDigit) { 179 | return _buildAnimatedDigit( 180 | oldDigit, 181 | newDigit, 182 | progress, 183 | style, 184 | digitWidth, 185 | digitHeight, 186 | ); 187 | } else { 188 | return SizedBox( 189 | width: digitWidth, 190 | height: digitHeight, 191 | child: Center(child: Text(char, style: style)), 192 | ); 193 | } 194 | } else { 195 | final charPainter = TextPainter( 196 | text: TextSpan(text: char, style: style), 197 | textDirection: TextDirection.ltr, 198 | )..layout(); 199 | 200 | return SizedBox( 201 | width: charPainter.width + 1, 202 | height: digitHeight, 203 | child: Center(child: Text(char, style: style)), 204 | ); 205 | } 206 | }).toList(), 207 | ); 208 | } 209 | 210 | Widget _buildAnimatedDigit( 211 | int oldDigit, 212 | int newDigit, 213 | double progress, 214 | TextStyle style, 215 | double digitWidth, 216 | double digitHeight, 217 | ) { 218 | return SizedBox( 219 | width: digitWidth, 220 | height: digitHeight, 221 | child: ClipRect( 222 | child: Stack( 223 | alignment: Alignment.center, 224 | clipBehavior: Clip.hardEdge, 225 | children: [ 226 | // Old digit sliding up and out 227 | Transform.translate( 228 | offset: Offset(0, -digitHeight * progress), 229 | child: Opacity( 230 | opacity: 1 - progress, 231 | child: Text(oldDigit.toString(), style: style), 232 | ), 233 | ), 234 | // New digit sliding up from below 235 | Transform.translate( 236 | offset: Offset(0, digitHeight * (1 - progress)), 237 | child: Opacity( 238 | opacity: progress, 239 | child: Text(newDigit.toString(), style: style), 240 | ), 241 | ), 242 | ], 243 | ), 244 | ), 245 | ); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /lib/widgets/spending_chart.dart: -------------------------------------------------------------------------------- 1 | import 'package:fl_chart/fl_chart.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:intl/intl.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:flutter_animate/flutter_animate.dart'; 6 | import '../providers/money_provider.dart'; 7 | import '../utils/app_theme.dart'; 8 | 9 | class SpendingChart extends StatelessWidget { 10 | const SpendingChart({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final provider = Provider.of(context); 15 | final transactions = provider.transactions 16 | .where((t) => t.isExpense) 17 | .toList(); 18 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 19 | final isLight = provider.appThemeMode == AppThemeMode.light; 20 | 21 | // Group expenses by day for the last 7 days 22 | final List dailyTotals = List.filled(7, 0.0); 23 | final now = DateTime.now(); 24 | final today = DateTime(now.year, now.month, now.day); 25 | 26 | for (var t in transactions) { 27 | final date = DateTime(t.date.year, t.date.month, t.date.day); 28 | final difference = today.difference(date).inDays; 29 | if (difference >= 0 && difference < 7) { 30 | dailyTotals[6 - difference] += t.amount; 31 | } 32 | } 33 | 34 | // Find max value for Y-axis scaling 35 | double maxY = dailyTotals.reduce((curr, next) => curr > next ? curr : next); 36 | if (maxY == 0) maxY = 100; // Default if no data 37 | 38 | return AspectRatio( 39 | aspectRatio: 1.70, 40 | child: Padding( 41 | padding: const EdgeInsets.only( 42 | right: 18, 43 | left: 12, 44 | top: 24, 45 | bottom: 12, 46 | ), 47 | child: LineChart( 48 | LineChartData( 49 | gridData: FlGridData( 50 | show: true, 51 | drawVerticalLine: false, 52 | horizontalInterval: maxY / 4, 53 | getDrawingHorizontalLine: (value) { 54 | return FlLine( 55 | color: isLight 56 | ? Colors.black.withOpacity(0.5) 57 | : Colors.white.withOpacity(0.5), 58 | strokeWidth: 1, 59 | ); 60 | }, 61 | ), 62 | titlesData: FlTitlesData( 63 | show: true, 64 | rightTitles: AxisTitles( 65 | sideTitles: SideTitles(showTitles: false), 66 | ), 67 | topTitles: AxisTitles( 68 | sideTitles: SideTitles(showTitles: false), 69 | ), 70 | bottomTitles: AxisTitles( 71 | sideTitles: SideTitles( 72 | showTitles: true, 73 | reservedSize: 30, 74 | interval: 1, 75 | getTitlesWidget: (value, meta) { 76 | final index = value.toInt(); 77 | if (index >= 0 && index < 7) { 78 | final date = today.subtract( 79 | Duration(days: 6 - index), 80 | ); 81 | return SideTitleWidget( 82 | meta: meta, 83 | child: Text( 84 | DateFormat('E').format(date), 85 | style: TextStyle( 86 | color: isLight 87 | ? Colors.black54 88 | : Colors.white54, 89 | fontWeight: FontWeight.bold, 90 | fontSize: 12, 91 | ), 92 | ), 93 | ); 94 | } 95 | return const SizedBox(); 96 | }, 97 | ), 98 | ), 99 | leftTitles: AxisTitles( 100 | sideTitles: SideTitles( 101 | showTitles: true, 102 | interval: maxY / 4, 103 | getTitlesWidget: (value, meta) { 104 | return Text( 105 | NumberFormat.compact().format(value), 106 | style: TextStyle( 107 | color: isLight ? Colors.black54 : Colors.white54, 108 | fontWeight: FontWeight.bold, 109 | fontSize: 12, 110 | ), 111 | textAlign: TextAlign.left, 112 | ); 113 | }, 114 | reservedSize: 42, 115 | ), 116 | ), 117 | ), 118 | borderData: FlBorderData(show: false), 119 | minX: 0, 120 | maxX: 6, 121 | minY: 0, 122 | maxY: maxY * 1.2, // Add some padding on top 123 | lineBarsData: [ 124 | LineChartBarData( 125 | spots: List.generate(7, (index) { 126 | return FlSpot(index.toDouble(), dailyTotals[index]); 127 | }), 128 | isCurved: true, 129 | gradient: LinearGradient( 130 | colors: [ 131 | isAmoled 132 | ? Colors.white 133 | : (isLight ? Colors.black : AppTheme.expense), 134 | isAmoled 135 | ? Colors.white60 136 | : (isLight 137 | ? Colors.black54 138 | : AppTheme.expense.withOpacity(0.5)), 139 | ], 140 | ), 141 | barWidth: 5, 142 | isStrokeCapRound: true, 143 | dotData: FlDotData( 144 | show: true, 145 | getDotPainter: (spot, percent, barData, index) { 146 | return FlDotCirclePainter( 147 | radius: 4, 148 | color: isLight 149 | ? Colors.white 150 | : (isAmoled ? Colors.black : Colors.white), 151 | strokeWidth: 2, 152 | strokeColor: isAmoled 153 | ? Colors.white 154 | : (isLight ? Colors.black : AppTheme.expense), 155 | ); 156 | }, 157 | ), 158 | belowBarData: BarAreaData( 159 | show: true, 160 | gradient: LinearGradient( 161 | colors: [ 162 | (isAmoled 163 | ? Colors.white 164 | : (isLight ? Colors.black : AppTheme.expense)) 165 | .withOpacity(0.3), 166 | (isAmoled 167 | ? Colors.white 168 | : (isLight ? Colors.black : AppTheme.expense)) 169 | .withOpacity(0.0), 170 | ], 171 | begin: Alignment.topCenter, 172 | end: Alignment.bottomCenter, 173 | ), 174 | ), 175 | ), 176 | ], 177 | ), 178 | ), 179 | ), 180 | ) 181 | .animate() 182 | .scale( 183 | begin: const Offset(0, 0), 184 | end: const Offset(1, 1), 185 | duration: 800.ms, 186 | curve: Curves.easeOutBack, 187 | alignment: Alignment.center, 188 | ) 189 | .fadeIn(duration: 500.ms); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /lib/widgets/skeleton_loading.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A shimmer effect widget for skeleton loading 4 | class ShimmerEffect extends StatefulWidget { 5 | final Widget child; 6 | 7 | const ShimmerEffect({super.key, required this.child}); 8 | 9 | @override 10 | State createState() => _ShimmerEffectState(); 11 | } 12 | 13 | class _ShimmerEffectState extends State 14 | with SingleTickerProviderStateMixin { 15 | late AnimationController _controller; 16 | late Animation _animation; 17 | 18 | @override 19 | void initState() { 20 | super.initState(); 21 | _controller = AnimationController( 22 | vsync: this, 23 | duration: const Duration(milliseconds: 1500), 24 | )..repeat(); 25 | 26 | _animation = Tween(begin: -2, end: 2).animate( 27 | CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine), 28 | ); 29 | } 30 | 31 | @override 32 | void dispose() { 33 | _controller.dispose(); 34 | super.dispose(); 35 | } 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | return AnimatedBuilder( 40 | animation: _animation, 41 | builder: (context, child) { 42 | return ShaderMask( 43 | shaderCallback: (bounds) { 44 | return LinearGradient( 45 | begin: Alignment.topLeft, 46 | end: Alignment.bottomRight, 47 | colors: [ 48 | Colors.grey.shade800, 49 | Colors.grey.shade600, 50 | Colors.grey.shade800, 51 | ], 52 | stops: [0.0, (_animation.value + 2) / 4, 1.0], 53 | transform: GradientRotation(_animation.value), 54 | ).createShader(bounds); 55 | }, 56 | blendMode: BlendMode.srcATop, 57 | child: widget.child, 58 | ); 59 | }, 60 | ); 61 | } 62 | } 63 | 64 | /// Skeleton box for loading placeholders 65 | class SkeletonBox extends StatelessWidget { 66 | final double width; 67 | final double height; 68 | final double borderRadius; 69 | 70 | const SkeletonBox({ 71 | super.key, 72 | required this.width, 73 | required this.height, 74 | this.borderRadius = 8, 75 | }); 76 | 77 | @override 78 | Widget build(BuildContext context) { 79 | return Container( 80 | width: width, 81 | height: height, 82 | decoration: BoxDecoration( 83 | color: Colors.grey.shade800, 84 | borderRadius: BorderRadius.circular(borderRadius), 85 | ), 86 | ); 87 | } 88 | } 89 | 90 | /// Skeleton loading for a single transaction item 91 | class TransactionSkeletonItem extends StatelessWidget { 92 | const TransactionSkeletonItem({super.key}); 93 | 94 | @override 95 | Widget build(BuildContext context) { 96 | return Container( 97 | margin: const EdgeInsets.only(bottom: 12), 98 | padding: const EdgeInsets.all(16), 99 | decoration: BoxDecoration( 100 | color: Colors.white.withOpacity(0.05), 101 | borderRadius: BorderRadius.circular(16), 102 | border: Border.all(color: Colors.white.withOpacity(0.1), width: 1), 103 | ), 104 | child: Row( 105 | children: [ 106 | // Icon placeholder 107 | SkeletonBox(width: 48, height: 48, borderRadius: 12), 108 | const SizedBox(width: 16), 109 | // Text placeholders 110 | Expanded( 111 | child: Column( 112 | crossAxisAlignment: CrossAxisAlignment.start, 113 | children: [ 114 | SkeletonBox(width: 120, height: 16, borderRadius: 4), 115 | const SizedBox(height: 8), 116 | SkeletonBox(width: 80, height: 12, borderRadius: 4), 117 | ], 118 | ), 119 | ), 120 | // Amount placeholder 121 | SkeletonBox(width: 70, height: 20, borderRadius: 4), 122 | ], 123 | ), 124 | ); 125 | } 126 | } 127 | 128 | /// Skeleton loading for the transaction list 129 | class TransactionListSkeleton extends StatelessWidget { 130 | final int itemCount; 131 | 132 | const TransactionListSkeleton({super.key, this.itemCount = 5}); 133 | 134 | @override 135 | Widget build(BuildContext context) { 136 | return ShimmerEffect( 137 | child: SingleChildScrollView( 138 | physics: const NeverScrollableScrollPhysics(), 139 | child: Column( 140 | children: List.generate( 141 | itemCount, 142 | (index) => const TransactionSkeletonItem(), 143 | ), 144 | ), 145 | ), 146 | ); 147 | } 148 | } 149 | 150 | /// Skeleton loading for the balance card 151 | class BalanceCardSkeleton extends StatelessWidget { 152 | const BalanceCardSkeleton({super.key}); 153 | 154 | @override 155 | Widget build(BuildContext context) { 156 | return ShimmerEffect( 157 | child: Container( 158 | height: 220, 159 | margin: const EdgeInsets.all(16), 160 | padding: const EdgeInsets.all(24), 161 | decoration: BoxDecoration( 162 | gradient: LinearGradient( 163 | colors: [Colors.grey.shade900, Colors.grey.shade800], 164 | begin: Alignment.topLeft, 165 | end: Alignment.bottomRight, 166 | ), 167 | borderRadius: BorderRadius.circular(24), 168 | ), 169 | child: Column( 170 | crossAxisAlignment: CrossAxisAlignment.start, 171 | children: [ 172 | // Card name placeholder 173 | Row( 174 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 175 | children: [ 176 | SkeletonBox(width: 100, height: 20, borderRadius: 4), 177 | SkeletonBox(width: 40, height: 24, borderRadius: 4), 178 | ], 179 | ), 180 | const Spacer(), 181 | // Balance label 182 | SkeletonBox(width: 80, height: 14, borderRadius: 4), 183 | const SizedBox(height: 8), 184 | // Balance amount 185 | SkeletonBox(width: 180, height: 36, borderRadius: 4), 186 | const SizedBox(height: 16), 187 | // Card number placeholder 188 | Row( 189 | children: [ 190 | SkeletonBox(width: 40, height: 12, borderRadius: 4), 191 | const SizedBox(width: 12), 192 | SkeletonBox(width: 40, height: 12, borderRadius: 4), 193 | const SizedBox(width: 12), 194 | SkeletonBox(width: 40, height: 12, borderRadius: 4), 195 | const SizedBox(width: 12), 196 | SkeletonBox(width: 40, height: 12, borderRadius: 4), 197 | ], 198 | ), 199 | ], 200 | ), 201 | ), 202 | ); 203 | } 204 | } 205 | 206 | /// Full home screen skeleton 207 | class HomeScreenSkeleton extends StatelessWidget { 208 | const HomeScreenSkeleton({super.key}); 209 | 210 | @override 211 | Widget build(BuildContext context) { 212 | return SingleChildScrollView( 213 | physics: const NeverScrollableScrollPhysics(), 214 | child: Column( 215 | crossAxisAlignment: CrossAxisAlignment.start, 216 | children: [ 217 | const BalanceCardSkeleton(), 218 | Padding( 219 | padding: const EdgeInsets.symmetric(horizontal: 16), 220 | child: Column( 221 | crossAxisAlignment: CrossAxisAlignment.start, 222 | children: [ 223 | // Quick actions skeleton 224 | ShimmerEffect( 225 | child: Row( 226 | mainAxisAlignment: MainAxisAlignment.spaceAround, 227 | children: List.generate( 228 | 4, 229 | (index) => Column( 230 | children: [ 231 | SkeletonBox(width: 56, height: 56, borderRadius: 16), 232 | const SizedBox(height: 8), 233 | SkeletonBox(width: 48, height: 12, borderRadius: 4), 234 | ], 235 | ), 236 | ), 237 | ), 238 | ), 239 | const SizedBox(height: 24), 240 | // Recent transactions header skeleton 241 | ShimmerEffect( 242 | child: SkeletonBox(width: 160, height: 20, borderRadius: 4), 243 | ), 244 | const SizedBox(height: 16), 245 | // Transaction list skeleton 246 | const TransactionListSkeleton(itemCount: 5), 247 | ], 248 | ), 249 | ), 250 | ], 251 | ), 252 | ); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /final_analysis.txt: -------------------------------------------------------------------------------- 1 | Analyzing PennyWise... 2 | 3 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:958:21 • avoid_types_as_parameter_names 4 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:958:21 • avoid_types_as_parameter_names 5 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:964:21 • avoid_types_as_parameter_names 6 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:964:21 • avoid_types_as_parameter_names 7 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1120:21 • avoid_types_as_parameter_names 8 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1120:21 • avoid_types_as_parameter_names 9 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1134:21 • avoid_types_as_parameter_names 10 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1134:21 • avoid_types_as_parameter_names 11 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1148:21 • avoid_types_as_parameter_names 12 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1148:21 • avoid_types_as_parameter_names 13 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1179:5 • avoid_print 14 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1179:5 • avoid_print 15 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1180:5 • avoid_print 16 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1180:5 • avoid_print 17 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1181:5 • avoid_print 18 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1181:5 • avoid_print 19 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1216:5 • avoid_print 20 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1216:5 • avoid_print 21 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1219:5 • avoid_print 22 | info • Don't invoke 'print' in production code • lib/providers/money_provider.dart:1219:5 • avoid_print 23 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1244:69 • avoid_types_as_parameter_names 24 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1244:69 • avoid_types_as_parameter_names 25 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1252:19 • avoid_types_as_parameter_names 26 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1252:19 • avoid_types_as_parameter_names 27 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1258:19 • avoid_types_as_parameter_names 28 | info • The parameter name 'sum' matches a visible type name • lib/providers/money_provider.dart:1258:19 • avoid_types_as_parameter_names 29 | info • Statements in an if should be enclosed in a block • lib/providers/money_provider.dart:1712:7 • curly_braces_in_flow_control_structures 30 | info • Statements in an if should be enclosed in a block • lib/providers/money_provider.dart:1712:7 • curly_braces_in_flow_control_structures 31 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/category_management_screen.dart:745:56 • deprecated_member_use 32 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/category_management_screen.dart:745:56 • deprecated_member_use 33 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/category_management_screen.dart:745:71 • deprecated_member_use 34 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/category_management_screen.dart:745:71 • deprecated_member_use 35 | info • The import of 'package:flutter/services.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' • lib/screens/lock_screen.dart:2:8 • unnecessary_import 36 | info • Don't use 'BuildContext's across async gaps • lib/screens/settings_screen.dart:161:32 • use_build_context_synchronously 37 | warning • The member 'notifyListeners' can only be used within 'package:flutter/src/foundation/change_notifier.dart' or a test • lib/screens/settings_screen.dart:288:20 • invalid_use_of_visible_for_testing_member 38 | warning • The member 'notifyListeners' can only be used within instance members of subclasses of 'ChangeNotifier' • lib/screens/settings_screen.dart:288:20 • invalid_use_of_protected_member 39 | info • Don't use 'BuildContext's across async gaps • lib/screens/settings_screen.dart:290:32 • use_build_context_synchronously 40 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/settings_screen.dart:917:13 • deprecated_member_use 41 | info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • lib/screens/transaction_detail_screen.dart:469:29 • deprecated_member_use 42 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/screens/transaction_detail_screen.dart:740:33 • deprecated_member_use 43 | info • Don't use 'BuildContext's across async gaps • lib/screens/transaction_detail_screen.dart:1647:9 • use_build_context_synchronously 44 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:1987:30 • use_build_context_synchronously 45 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2004:36 • use_build_context_synchronously 46 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2008:7 • use_build_context_synchronously 47 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2523:26 • use_build_context_synchronously 48 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/screens/transaction_detail_screen.dart:2524:26 • use_build_context_synchronously 49 | info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/services/google_drive_service.dart:590:53 • deprecated_member_use 50 | info • Statements in a while should be enclosed in a block • lib/services/update_service.dart:123:38 • curly_braces_in_flow_control_structures 51 | info • Statements in a while should be enclosed in a block • lib/services/update_service.dart:124:39 • curly_braces_in_flow_control_structures 52 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:373:42 • use_build_context_synchronously 53 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1006:46 • use_build_context_synchronously 54 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1015:44 • use_build_context_synchronously 55 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1137:40 • use_build_context_synchronously 56 | info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/widgets/balance_card.dart:1144:40 • use_build_context_synchronously 57 | info • Unnecessary 'const' keyword • lib/widgets/card_designs.dart:5329:30 • unnecessary_const 58 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:245:9 • avoid_print 59 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:246:9 • avoid_print 60 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:247:9 • avoid_print 61 | info • Don't invoke 'print' in production code • lib/widgets/transaction_list.dart:248:9 • avoid_print 62 | 63 | -------------------------------------------------------------------------------- /lib/screens/advance_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import '../providers/money_provider.dart'; 4 | import '../utils/app_theme.dart'; 5 | import 'sms_tracking_screen.dart'; 6 | import 'net_worth_screen.dart'; 7 | import 'category_management_screen.dart'; 8 | import 'budget_planning_screen.dart'; 9 | import 'loans_screen.dart'; 10 | import 'goals_screen.dart'; 11 | import 'currency_converter_screen.dart'; 12 | 13 | class AdvanceScreen extends StatelessWidget { 14 | const AdvanceScreen({super.key}); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final provider = Provider.of(context); 19 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 20 | final isLight = provider.appThemeMode == AppThemeMode.light; 21 | final isHighContrast = isAmoled || isLight; 22 | 23 | return Scaffold( 24 | appBar: AppBar( 25 | backgroundColor: Colors.transparent, 26 | elevation: 0, 27 | title: Text( 28 | 'Advanced Features', 29 | style: TextStyle( 30 | color: Theme.of(context).textTheme.titleLarge?.color, 31 | ), 32 | ), 33 | ), 34 | body: Container( 35 | color: isHighContrast 36 | ? Theme.of(context).scaffoldBackgroundColor 37 | : null, 38 | child: ListView( 39 | padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), 40 | children: [ 41 | _buildFeatureTile( 42 | context, 43 | 'SMS Transaction Tracking', 44 | 'Automatically track transactions from SMS messages', 45 | Icons.sms_outlined, 46 | () => Navigator.push( 47 | context, 48 | MaterialPageRoute( 49 | builder: (context) => const SmsTrackingScreen(), 50 | ), 51 | ), 52 | isEnabled: provider.smsTrackingEnabled, 53 | ), 54 | _buildFeatureTile( 55 | context, 56 | 'Net Worth Analysis', 57 | 'Visualize your financial growth over time', 58 | Icons.show_chart_rounded, 59 | () => Navigator.push( 60 | context, 61 | MaterialPageRoute(builder: (context) => const NetWorthScreen()), 62 | ), 63 | ), 64 | _buildFeatureTile( 65 | context, 66 | 'Category Management', 67 | 'Create and customize transaction categories', 68 | Icons.category_outlined, 69 | () => Navigator.push( 70 | context, 71 | MaterialPageRoute( 72 | builder: (context) => const CategoryManagementScreen(), 73 | ), 74 | ), 75 | ), 76 | _buildFeatureTile( 77 | context, 78 | 'Budget Planning', 79 | 'Set monthly limits for categories', 80 | Icons.account_balance_wallet_outlined, 81 | () => Navigator.push( 82 | context, 83 | MaterialPageRoute( 84 | builder: (context) => const BudgetPlanningScreen(), 85 | ), 86 | ), 87 | ), 88 | _buildFeatureTile( 89 | context, 90 | 'Loans Management', 91 | 'Track money lent and borrowed', 92 | Icons.handshake_outlined, 93 | () => Navigator.push( 94 | context, 95 | MaterialPageRoute(builder: (context) => const LoansScreen()), 96 | ), 97 | ), 98 | _buildFeatureTile( 99 | context, 100 | 'Financial Goals', 101 | 'Set and track savings goals', 102 | Icons.flag_outlined, 103 | () => Navigator.push( 104 | context, 105 | MaterialPageRoute(builder: (context) => const GoalsScreen()), 106 | ), 107 | ), 108 | _buildFeatureTile( 109 | context, 110 | 'Currency Converter', 111 | 'Convert between world currencies with live rates', 112 | Icons.currency_exchange_rounded, 113 | () => Navigator.push( 114 | context, 115 | MaterialPageRoute( 116 | builder: (context) => const CurrencyConverterScreen(), 117 | ), 118 | ), 119 | ), 120 | // Add more advanced features here in the future 121 | ], 122 | ), 123 | ), 124 | ); 125 | } 126 | 127 | Widget _buildFeatureTile( 128 | BuildContext context, 129 | String title, 130 | String subtitle, 131 | IconData icon, 132 | VoidCallback onTap, { 133 | bool? isEnabled, 134 | }) { 135 | final isAmoled = 136 | Provider.of(context, listen: false).appThemeMode == 137 | AppThemeMode.amoled; 138 | final isLight = 139 | Provider.of(context, listen: false).appThemeMode == 140 | AppThemeMode.light; 141 | final isHighContrast = isAmoled || isLight; 142 | return GestureDetector( 143 | onTap: onTap, 144 | child: Container( 145 | margin: const EdgeInsets.only(bottom: 16), 146 | padding: const EdgeInsets.all(20), 147 | decoration: BoxDecoration( 148 | color: isHighContrast 149 | ? Colors.transparent 150 | : Theme.of(context).cardColor, 151 | borderRadius: BorderRadius.circular(20), 152 | border: Border.all( 153 | color: isHighContrast 154 | ? Theme.of(context).iconTheme.color!.withOpacity(0.2) 155 | : (isEnabled == true 156 | ? Theme.of(context).primaryColor.withOpacity(0.3) 157 | : Theme.of(context).dividerColor), 158 | ), 159 | boxShadow: isHighContrast 160 | ? null 161 | : [ 162 | BoxShadow( 163 | color: Theme.of(context).shadowColor.withOpacity(0.1), 164 | blurRadius: 10, 165 | offset: const Offset(0, 4), 166 | ), 167 | ], 168 | ), 169 | child: Row( 170 | children: [ 171 | Container( 172 | padding: const EdgeInsets.all(12), 173 | decoration: BoxDecoration( 174 | color: isHighContrast 175 | ? Colors.transparent 176 | : (isEnabled == true 177 | ? Theme.of( 178 | context, 179 | ).primaryColor.withOpacity(0.1) 180 | : Theme.of( 181 | context, 182 | ).colorScheme.surfaceContainerHighest), 183 | shape: BoxShape.circle, 184 | border: isHighContrast 185 | ? Border.all( 186 | color: Theme.of( 187 | context, 188 | ).iconTheme.color!.withOpacity(0.2), 189 | ) 190 | : null, 191 | ), 192 | child: Icon( 193 | icon, 194 | color: isHighContrast 195 | ? Theme.of(context).iconTheme.color 196 | : (isEnabled == true 197 | ? Theme.of(context).primaryColor 198 | : Theme.of(context).textTheme.bodySmall?.color 199 | ?.withOpacity(0.5)), 200 | size: 24, 201 | ), 202 | ), 203 | const SizedBox(width: 16), 204 | Expanded( 205 | child: Column( 206 | crossAxisAlignment: CrossAxisAlignment.start, 207 | children: [ 208 | Text( 209 | title, 210 | style: TextStyle( 211 | color: Theme.of(context).textTheme.titleMedium?.color, 212 | fontSize: 16, 213 | fontWeight: FontWeight.bold, 214 | ), 215 | ), 216 | const SizedBox(height: 4), 217 | Text( 218 | subtitle, 219 | style: TextStyle( 220 | color: Theme.of( 221 | context, 222 | ).textTheme.bodySmall?.color?.withOpacity(0.6), 223 | fontSize: 12, 224 | ), 225 | ), 226 | if (isEnabled != null) ...[ 227 | const SizedBox(height: 8), 228 | Container( 229 | padding: const EdgeInsets.symmetric( 230 | horizontal: 8, 231 | vertical: 4, 232 | ), 233 | decoration: BoxDecoration( 234 | color: isEnabled 235 | ? Colors.green.withOpacity(0.1) 236 | : Colors.red.withOpacity(0.1), 237 | borderRadius: BorderRadius.circular(8), 238 | ), 239 | child: Text( 240 | isEnabled ? 'Active' : 'Inactive', 241 | style: TextStyle( 242 | color: isEnabled ? Colors.green : Colors.red, 243 | fontSize: 10, 244 | fontWeight: FontWeight.bold, 245 | ), 246 | ), 247 | ), 248 | ], 249 | ], 250 | ), 251 | ), 252 | Icon( 253 | Icons.chevron_right, 254 | color: Theme.of( 255 | context, 256 | ).textTheme.bodySmall?.color?.withOpacity(0.3), 257 | ), 258 | ], 259 | ), 260 | ), 261 | ); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /lib/widgets/profile_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:flutter_animate/flutter_animate.dart'; 5 | import '../providers/money_provider.dart'; 6 | import '../services/auth_service.dart'; 7 | import '../utils/app_theme.dart'; 8 | 9 | class ProfileDialog extends StatelessWidget { 10 | const ProfileDialog({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final provider = Provider.of(context); 15 | 16 | final theme = Theme.of(context); 17 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 18 | 19 | return Scaffold( 20 | backgroundColor: Colors.transparent, 21 | body: Stack( 22 | children: [ 23 | // Blur Effect 24 | Positioned.fill( 25 | child: BackdropFilter( 26 | filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), 27 | child: Container(color: Colors.black.withOpacity(0.2)), 28 | ), 29 | ), 30 | Center( 31 | child: Material( 32 | color: theme.cardColor, 33 | borderRadius: BorderRadius.circular(32), 34 | elevation: 20, 35 | child: Container( 36 | width: MediaQuery.of(context).size.width * 0.85, 37 | padding: const EdgeInsets.all(32), 38 | decoration: BoxDecoration( 39 | borderRadius: BorderRadius.circular(32), 40 | border: Border.all( 41 | color: theme.dividerColor, 42 | width: 1, 43 | ), 44 | ), 45 | child: Stack( 46 | clipBehavior: Clip.none, 47 | children: [ 48 | Column( 49 | mainAxisSize: MainAxisSize.min, 50 | children: [ 51 | // Avatar with edit button 52 | Hero( 53 | tag: 'profile_ring', 54 | child: Stack( 55 | clipBehavior: Clip.none, 56 | alignment: Alignment.center, 57 | children: [ 58 | Container( 59 | padding: const EdgeInsets.all(4), 60 | decoration: BoxDecoration( 61 | shape: BoxShape.circle, 62 | border: Border.all( 63 | color: AppTheme.primary, 64 | width: 3, 65 | ), 66 | ), 67 | child: CircleAvatar( 68 | radius: 50, 69 | backgroundColor: 70 | isAmoled ? AppTheme.amoledSurface : theme.cardColor, 71 | backgroundImage: provider.photoURL != null 72 | ? NetworkImage(provider.photoURL!) 73 | : null, 74 | child: provider.photoURL == null 75 | ? Icon( 76 | Icons.person, 77 | size: 50, 78 | color: theme.iconTheme.color, 79 | ) 80 | : null, 81 | ), 82 | ), 83 | // Pencil button 84 | Positioned( 85 | right: -4, 86 | bottom: -4, 87 | child: Material( 88 | color: theme.cardColor, 89 | shape: const CircleBorder(), 90 | elevation: 4, 91 | child: InkWell( 92 | customBorder: const CircleBorder(), 93 | onTap: () => _showChangePhotoDialog(context, provider), 94 | child: Padding( 95 | padding: const EdgeInsets.all(8.0), 96 | child: Icon( 97 | Icons.edit, 98 | size: 18, 99 | color: theme.iconTheme.color, 100 | ), 101 | ), 102 | ), 103 | ), 104 | ), 105 | ], 106 | ), 107 | ), 108 | const SizedBox(height: 24), 109 | 110 | // User Name 111 | Text( 112 | provider.userName, 113 | textAlign: TextAlign.center, 114 | style: TextStyle( 115 | fontSize: 28, 116 | fontWeight: FontWeight.bold, 117 | color: theme.textTheme.titleLarge?.color, 118 | letterSpacing: 0.5, 119 | ), 120 | ).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2), 121 | 122 | const SizedBox(height: 8), 123 | 124 | // Email/Account Type 125 | Text( 126 | provider.userId != null && 127 | !provider.settingsBox.get( 128 | 'isGuest', 129 | defaultValue: true, 130 | ) 131 | ? 'Google Account' 132 | : 'Guest Account', 133 | style: TextStyle( 134 | fontSize: 14, 135 | color: theme.textTheme.bodySmall?.color?.withOpacity(0.6), 136 | ), 137 | ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.2), 138 | 139 | const SizedBox(height: 40), 140 | 141 | // Logout Button 142 | SizedBox( 143 | width: double.infinity, 144 | child: ElevatedButton.icon( 145 | onPressed: () async { 146 | // Close dialog first 147 | Navigator.pop(context); 148 | 149 | // Sign out logic 150 | await AuthService().signOut(); 151 | if (context.mounted) { 152 | await provider.logout(); 153 | if (context.mounted) { 154 | Navigator.pushNamedAndRemoveUntil( 155 | context, 156 | '/onboarding', 157 | (route) => false, 158 | ); 159 | } 160 | } 161 | }, 162 | icon: Icon( 163 | Icons.logout_rounded, 164 | color: theme.colorScheme.onError, 165 | ), 166 | label: Text( 167 | 'LOGOUT', 168 | style: TextStyle( 169 | fontSize: 16, 170 | fontWeight: FontWeight.bold, 171 | color: theme.colorScheme.onError, 172 | letterSpacing: 1.0, 173 | ), 174 | ), 175 | style: ElevatedButton.styleFrom( 176 | backgroundColor: AppTheme.expense, 177 | padding: const EdgeInsets.symmetric(vertical: 16), 178 | shape: RoundedRectangleBorder( 179 | borderRadius: BorderRadius.circular(16), 180 | ), 181 | elevation: 0, 182 | ), 183 | ), 184 | ).animate().fadeIn(delay: 400.ms).slideY(begin: 0.2), 185 | ], 186 | ), 187 | 188 | // Close Button 189 | Positioned( 190 | top: -16, 191 | right: -16, 192 | child: IconButton( 193 | onPressed: () => Navigator.pop(context), 194 | icon: Icon(Icons.close, color: theme.iconTheme.color?.withOpacity(0.6)), 195 | splashRadius: 20, 196 | ), 197 | ), 198 | ], 199 | ), 200 | ), 201 | ), 202 | ), 203 | ], 204 | ), 205 | ); 206 | } 207 | 208 | void _showChangePhotoDialog(BuildContext context, MoneyProvider provider) { 209 | final controller = TextEditingController(text: provider.photoURL ?? ''); 210 | showDialog( 211 | context: context, 212 | builder: (context) => AlertDialog( 213 | title: const Text('Change profile picture'), 214 | content: TextField( 215 | controller: controller, 216 | decoration: const InputDecoration( 217 | hintText: 'Enter image URL', 218 | ), 219 | ), 220 | actions: [ 221 | TextButton( 222 | onPressed: () { 223 | Navigator.pop(context); 224 | }, 225 | child: const Text('CANCEL'), 226 | ), 227 | TextButton( 228 | onPressed: () async { 229 | final url = controller.text.trim(); 230 | await provider.setPhotoURL(url.isEmpty ? null : url); 231 | if (context.mounted) Navigator.pop(context); 232 | }, 233 | child: const Text('SAVE'), 234 | ), 235 | if (provider.photoURL != null) ...[ 236 | TextButton( 237 | onPressed: () async { 238 | await provider.setPhotoURL(null); 239 | if (context.mounted) Navigator.pop(context); 240 | }, 241 | child: const Text('REMOVE'), 242 | ), 243 | ], 244 | ], 245 | ), 246 | ); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /lib/screens/design_playground_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | 4 | import 'package:pennywise/widgets/card_designs.dart'; 5 | import '../providers/money_provider.dart'; 6 | 7 | class DesignPlaygroundScreen extends StatelessWidget { 8 | const DesignPlaygroundScreen({super.key}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | final isLight = Theme.of(context).brightness == Brightness.light; 13 | return Scaffold( 14 | backgroundColor: isLight ? Colors.white : const Color(0xFF0F111A), 15 | appBar: AppBar( 16 | backgroundColor: Colors.transparent, 17 | elevation: 0, 18 | leading: IconButton( 19 | icon: Icon( 20 | Icons.arrow_back, 21 | color: isLight ? Colors.black : Colors.white, 22 | ), 23 | onPressed: () => Navigator.pop(context), 24 | ), 25 | title: Text( 26 | 'Design Playground', 27 | style: TextStyle(color: isLight ? Colors.black : Colors.white), 28 | ), 29 | ), 30 | body: Consumer( 31 | builder: (context, provider, _) => ListView( 32 | padding: const EdgeInsets.all(16), 33 | children: [ 34 | // Default Card Option 35 | CardDesignSelector( 36 | designId: 'default', 37 | provider: provider, 38 | label: 'Default', 39 | child: Container( 40 | height: 180, 41 | decoration: BoxDecoration( 42 | borderRadius: BorderRadius.circular(24), 43 | color: isLight 44 | ? Colors.transparent 45 | : Colors.white.withOpacity(0.04), 46 | border: isLight ? Border.all(color: Colors.black) : null, 47 | ), 48 | child: Center( 49 | child: Icon( 50 | Icons.credit_card, 51 | color: isLight 52 | ? Colors.black 53 | : Colors.white.withOpacity(0.7), 54 | size: 60, 55 | ), 56 | ), 57 | ), 58 | ), 59 | 60 | // Card Design Options 61 | // NEW NATURE & THEME DESIGNS 62 | CardDesignSelector( 63 | designId: 'ocean_wave', 64 | provider: provider, 65 | label: 'Ocean Wave', 66 | child: OceanWaveHeader(provider: provider), 67 | ), 68 | CardDesignSelector( 69 | designId: 'forest_green', 70 | provider: provider, 71 | label: 'Forest Green', 72 | child: ForestGreenHeader(provider: provider), 73 | ), 74 | CardDesignSelector( 75 | designId: 'sunset_orange', 76 | provider: provider, 77 | label: 'Sunset Orange', 78 | child: SunsetOrangeHeader(provider: provider), 79 | ), 80 | CardDesignSelector( 81 | designId: 'midnight_blue', 82 | provider: provider, 83 | label: 'Midnight Blue', 84 | child: MidnightBlueHeader(provider: provider), 85 | ), 86 | CardDesignSelector( 87 | designId: 'lavender_dream', 88 | provider: provider, 89 | label: 'Lavender Dream', 90 | child: LavenderDreamHeader(provider: provider), 91 | ), 92 | CardDesignSelector( 93 | designId: 'crimson_red', 94 | provider: provider, 95 | label: 'Crimson Red', 96 | child: CrimsonRedHeader(provider: provider), 97 | ), 98 | CardDesignSelector( 99 | designId: 'arctic_white', 100 | provider: provider, 101 | label: 'Arctic White', 102 | child: ArcticWhiteHeader(provider: provider), 103 | ), 104 | CardDesignSelector( 105 | designId: 'desert_sand', 106 | provider: provider, 107 | label: 'Desert Sand', 108 | child: DesertSandHeader(provider: provider), 109 | ), 110 | CardDesignSelector( 111 | designId: 'galaxy_purple', 112 | provider: provider, 113 | label: 'Galaxy Purple', 114 | child: GalaxyPurpleHeader(provider: provider), 115 | ), 116 | CardDesignSelector( 117 | designId: 'emerald_green', 118 | provider: provider, 119 | label: 'Emerald Green', 120 | child: EmeraldGreenHeader(provider: provider), 121 | ), 122 | 123 | // GLASSMORPHISM COLLECTION 124 | _buildSectionTitle(context, '━━━ GLASSMORPHISM COLLECTION ━━━'), 125 | const SizedBox(height: 24), 126 | 127 | CardDesignSelector( 128 | designId: 'amex_platinum_glass', 129 | provider: provider, 130 | label: 'Amex Platinum Glass', 131 | child: AmexPlatinumGlassHeader(provider: provider), 132 | ), 133 | CardDesignSelector( 134 | designId: 'amex_gold_frosted', 135 | provider: provider, 136 | label: 'Amex Gold Frosted', 137 | child: AmexGoldFrostedHeader(provider: provider), 138 | ), 139 | CardDesignSelector( 140 | designId: 'amex_centurion', 141 | provider: provider, 142 | label: 'Amex Centurion (Black)', 143 | child: AmexCenturionHeader(provider: provider), 144 | ), 145 | CardDesignSelector( 146 | designId: 'visa_infinite_glass', 147 | provider: provider, 148 | label: 'Visa Infinite Glass', 149 | child: VisaInfiniteGlassHeader(provider: provider), 150 | ), 151 | CardDesignSelector( 152 | designId: 'mastercard_world_elite', 153 | provider: provider, 154 | label: 'Mastercard World Elite', 155 | child: MastercardWorldEliteHeader(provider: provider), 156 | ), 157 | CardDesignSelector( 158 | designId: 'frosted_ocean_glass', 159 | provider: provider, 160 | label: 'Frosted Ocean Glass', 161 | child: FrostedOceanGlassHeader(provider: provider), 162 | ), 163 | CardDesignSelector( 164 | designId: 'aurora_borealis_glass', 165 | provider: provider, 166 | label: 'Aurora Borealis Glass', 167 | child: AuroraBorealisGlassHeader(provider: provider), 168 | ), 169 | CardDesignSelector( 170 | designId: 'sapphire_reserve_glass', 171 | provider: provider, 172 | label: 'Sapphire Reserve Glass', 173 | child: SapphireReserveGlassHeader(provider: provider), 174 | ), 175 | const SizedBox(height: 32), 176 | 177 | // FUTURE & COSMIC COLLECTION 178 | _buildSectionTitle(context, '━━━ FUTURE & COSMIC ━━━'), 179 | const SizedBox(height: 24), 180 | 181 | CardDesignSelector( 182 | designId: 'cosmic_nebula', 183 | provider: provider, 184 | label: 'Cosmic Nebula', 185 | child: CosmicNebulaHeader(provider: provider), 186 | ), 187 | CardDesignSelector( 188 | designId: 'quantum_dot', 189 | provider: provider, 190 | label: 'Quantum Dot', 191 | child: QuantumDotHeader(provider: provider), 192 | ), 193 | CardDesignSelector( 194 | designId: 'liquid_gold', 195 | provider: provider, 196 | label: 'Liquid Gold', 197 | child: LiquidGoldHeader(provider: provider), 198 | ), 199 | CardDesignSelector( 200 | designId: 'cyber_glitch', 201 | provider: provider, 202 | label: 'Cyber Glitch', 203 | child: CyberGlitchHeader(provider: provider), 204 | ), 205 | CardDesignSelector( 206 | designId: 'zen_garden', 207 | provider: provider, 208 | label: 'Zen Garden', 209 | child: ZenGardenHeader(provider: provider), 210 | ), 211 | CardDesignSelector( 212 | designId: 'retro_vaporwave', 213 | provider: provider, 214 | label: 'Retro Vaporwave', 215 | child: RetroVaporwaveHeader(provider: provider), 216 | ), 217 | CardDesignSelector( 218 | designId: 'neon_city', 219 | provider: provider, 220 | label: 'Neon City', 221 | child: NeonCityHeader(provider: provider), 222 | ), 223 | CardDesignSelector( 224 | designId: 'prism_refraction', 225 | provider: provider, 226 | label: 'Prism Refraction', 227 | child: PrismRefractionHeader(provider: provider), 228 | ), 229 | CardDesignSelector( 230 | designId: 'obsidian_shard', 231 | provider: provider, 232 | label: 'Obsidian Shard', 233 | child: ObsidianShardHeader(provider: provider), 234 | ), 235 | CardDesignSelector( 236 | designId: 'bioluminescence', 237 | provider: provider, 238 | label: 'Bioluminescence', 239 | child: BioluminescenceHeader(provider: provider), 240 | ), 241 | const SizedBox(height: 32), 242 | ], 243 | ), 244 | ), 245 | ); 246 | } 247 | 248 | Widget _buildSectionTitle(BuildContext context, String title) { 249 | final isLight = Theme.of(context).brightness == Brightness.light; 250 | return Text( 251 | title, 252 | style: TextStyle( 253 | color: isLight 254 | ? Colors.black.withOpacity(0.7) 255 | : Colors.white.withOpacity(0.7), 256 | fontSize: 14, 257 | fontWeight: FontWeight.bold, 258 | letterSpacing: 1.0, 259 | ), 260 | ); 261 | } 262 | } 263 | 264 | // --- Card Design Selector Widget --- 265 | class CardDesignSelector extends StatelessWidget { 266 | final String designId; 267 | final MoneyProvider provider; 268 | final Widget child; 269 | final String? label; 270 | const CardDesignSelector({ 271 | required this.designId, 272 | required this.provider, 273 | required this.child, 274 | this.label, 275 | super.key, 276 | }); 277 | @override 278 | Widget build(BuildContext context) { 279 | final isSelected = provider.selectedCardDesign == designId; 280 | return GestureDetector( 281 | onTap: () => provider.setSelectedCardDesign(designId), 282 | child: Container( 283 | margin: const EdgeInsets.only(bottom: 24), 284 | decoration: BoxDecoration( 285 | borderRadius: BorderRadius.circular(24), 286 | border: Border.all( 287 | color: isSelected ? Colors.amber : Colors.transparent, 288 | width: 3, 289 | ), 290 | boxShadow: isSelected 291 | ? [ 292 | BoxShadow( 293 | color: Colors.amber.withOpacity(0.2), 294 | blurRadius: 16, 295 | ), 296 | ] 297 | : [], 298 | ), 299 | child: Stack( 300 | children: [ 301 | child, 302 | if (isSelected) 303 | Positioned( 304 | top: 12, 305 | right: 12, 306 | child: Icon(Icons.check_circle, color: Colors.amber, size: 28), 307 | ), 308 | if (label != null) 309 | Positioned( 310 | left: 16, 311 | top: 16, 312 | child: Container( 313 | padding: const EdgeInsets.symmetric( 314 | horizontal: 10, 315 | vertical: 4, 316 | ), 317 | decoration: BoxDecoration( 318 | color: Colors.black.withOpacity(0.4), 319 | borderRadius: BorderRadius.circular(12), 320 | ), 321 | child: Text( 322 | label!, 323 | style: const TextStyle( 324 | color: Colors.white, 325 | fontWeight: FontWeight.bold, 326 | fontSize: 12, 327 | letterSpacing: 1.0, 328 | ), 329 | ), 330 | ), 331 | ), 332 | ], 333 | ), 334 | ), 335 | ); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /lib/screens/sms_permission_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:flutter_animate/flutter_animate.dart'; 4 | import 'package:permission_handler/permission_handler.dart'; 5 | import '../providers/money_provider.dart'; 6 | import '../utils/app_theme.dart'; 7 | 8 | class SmsPermissionScreen extends StatelessWidget { 9 | const SmsPermissionScreen({super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final provider = Provider.of(context); 14 | final isAmoled = provider.appThemeMode == AppThemeMode.amoled; 15 | final theme = Theme.of(context); 16 | 17 | return Scaffold( 18 | backgroundColor: isAmoled ? Colors.black : theme.scaffoldBackgroundColor, 19 | body: SafeArea( 20 | child: Container( 21 | decoration: BoxDecoration( 22 | color: isAmoled ? Colors.black : theme.scaffoldBackgroundColor, 23 | ), 24 | child: Column( 25 | children: [ 26 | // Custom App Bar 27 | Padding( 28 | padding: const EdgeInsets.all(16), 29 | child: Row( 30 | children: [ 31 | IconButton( 32 | icon: const Icon(Icons.close, color: Colors.white), 33 | onPressed: () => Navigator.pop(context), 34 | ), 35 | ], 36 | ), 37 | ), 38 | 39 | Expanded( 40 | child: Padding( 41 | padding: const EdgeInsets.symmetric(horizontal: 24), 42 | child: Column( 43 | mainAxisAlignment: MainAxisAlignment.center, 44 | children: [ 45 | // Hero Icon 46 | Container( 47 | padding: const EdgeInsets.all(32), 48 | decoration: BoxDecoration( 49 | color: isAmoled 50 | ? Colors.transparent 51 | : AppTheme.primary.withOpacity(0.1), 52 | shape: BoxShape.circle, 53 | border: Border.all( 54 | color: isAmoled 55 | ? Colors.white 56 | : AppTheme.primary.withOpacity(0.3), 57 | width: 2, 58 | ), 59 | boxShadow: isAmoled 60 | ? null 61 | : [ 62 | BoxShadow( 63 | color: AppTheme.primary.withOpacity(0.2), 64 | blurRadius: 40, 65 | spreadRadius: 10, 66 | ), 67 | ], 68 | ), 69 | child: Icon( 70 | Icons.security, 71 | size: 64, 72 | color: isAmoled ? Colors.white : AppTheme.primary, 73 | ), 74 | ).animate().scale( 75 | duration: 500.ms, 76 | curve: Curves.easeOutBack, 77 | ), 78 | 79 | const SizedBox(height: 40), 80 | 81 | // Title 82 | const Text( 83 | 'SMS Permission', 84 | style: TextStyle( 85 | fontSize: 32, 86 | fontWeight: FontWeight.bold, 87 | color: Colors.white, 88 | letterSpacing: 0.5, 89 | ), 90 | ).animate().fadeIn().slideY(begin: 0.3), 91 | 92 | const SizedBox(height: 16), 93 | 94 | // Description 95 | Text( 96 | 'PennyWise needs access to your SMS messages to automatically track your expenses and bill reminders.', 97 | textAlign: TextAlign.center, 98 | style: TextStyle( 99 | fontSize: 16, 100 | color: Colors.white.withOpacity(0.7), 101 | height: 1.5, 102 | ), 103 | ).animate().fadeIn(delay: 100.ms).slideY(begin: 0.3), 104 | 105 | const SizedBox(height: 48), 106 | 107 | // Features List 108 | _buildFeatureRow( 109 | context, 110 | Icons.lock_outline, 111 | 'Private & Secure', 112 | 'Your data never leaves your device', 113 | ).animate().fadeIn(delay: 200.ms).slideX(begin: -0.2), 114 | 115 | const SizedBox(height: 24), 116 | 117 | _buildFeatureRow( 118 | context, 119 | Icons.notifications_off_outlined, 120 | 'No Spam', 121 | 'We only read transactional messages', 122 | ).animate().fadeIn(delay: 300.ms).slideX(begin: -0.2), 123 | 124 | const SizedBox(height: 24), 125 | 126 | _buildFeatureRow( 127 | context, 128 | Icons.battery_charging_full, 129 | 'Battery Efficient', 130 | 'Optimized for minimal battery usage', 131 | ).animate().fadeIn(delay: 400.ms).slideX(begin: -0.2), 132 | ], 133 | ), 134 | ), 135 | ), 136 | 137 | // Bottom Section 138 | Padding( 139 | padding: const EdgeInsets.all(24), 140 | child: Column( 141 | mainAxisSize: MainAxisSize.min, 142 | children: [ 143 | // Grant Button 144 | SizedBox( 145 | width: double.infinity, 146 | height: 56, 147 | child: Consumer( 148 | builder: (context, provider, _) { 149 | final isAmoled = 150 | provider.appThemeMode == AppThemeMode.amoled; 151 | return ElevatedButton( 152 | onPressed: () async { 153 | final status = await Permission.sms.request(); 154 | 155 | if (context.mounted) { 156 | if (status.isGranted) { 157 | provider.setSmsTracking(true); 158 | // Start syncing SMS immediately 159 | provider.syncSmsTransactions(); 160 | 161 | Navigator.pop( 162 | context, 163 | ); // Close permission screen 164 | Navigator.pop( 165 | context, 166 | ); // Close tracking screen to return to advanced settings 167 | } else if (status.isPermanentlyDenied) { 168 | showDialog( 169 | context: context, 170 | builder: (context) => AlertDialog( 171 | backgroundColor: isAmoled 172 | ? Colors.black 173 | : AppTheme.surface, 174 | shape: isAmoled 175 | ? RoundedRectangleBorder( 176 | borderRadius: 177 | BorderRadius.circular(20), 178 | side: const BorderSide( 179 | color: Colors.white, 180 | ), 181 | ) 182 | : null, 183 | title: const Text( 184 | 'Permission Required', 185 | style: TextStyle(color: Colors.white), 186 | ), 187 | content: const Text( 188 | 'SMS permission is required to track transactions. Please enable it in settings.', 189 | style: TextStyle(color: Colors.white70), 190 | ), 191 | actions: [ 192 | TextButton( 193 | onPressed: () => 194 | Navigator.pop(context), 195 | child: const Text('Cancel'), 196 | ), 197 | TextButton( 198 | onPressed: () { 199 | Navigator.pop(context); 200 | openAppSettings(); 201 | }, 202 | child: const Text('Open Settings'), 203 | ), 204 | ], 205 | ), 206 | ); 207 | } 208 | } 209 | }, 210 | style: ElevatedButton.styleFrom( 211 | backgroundColor: isAmoled 212 | ? Colors.white 213 | : AppTheme.primary, 214 | foregroundColor: isAmoled 215 | ? Colors.black 216 | : Colors.white, 217 | shape: RoundedRectangleBorder( 218 | borderRadius: BorderRadius.circular(16), 219 | side: isAmoled 220 | ? const BorderSide(color: Colors.white) 221 | : BorderSide.none, 222 | ), 223 | elevation: isAmoled ? 0 : 8, 224 | shadowColor: isAmoled 225 | ? Colors.transparent 226 | : AppTheme.primary.withOpacity(0.5), 227 | ), 228 | child: const Text( 229 | 'GRANT PERMISSION', 230 | style: TextStyle( 231 | fontSize: 16, 232 | fontWeight: FontWeight.bold, 233 | letterSpacing: 1.0, 234 | ), 235 | ), 236 | ); 237 | }, 238 | ), 239 | ).animate().fadeIn(delay: 500.ms).slideY(begin: 1.0), 240 | 241 | const SizedBox(height: 16), 242 | 243 | // Footer Text 244 | Text( 245 | 'You can revoke this permission at any time in settings.', 246 | textAlign: TextAlign.center, 247 | style: TextStyle( 248 | color: Colors.white.withOpacity(0.4), 249 | fontSize: 12, 250 | ), 251 | ), 252 | ], 253 | ), 254 | ), 255 | ], 256 | ), 257 | ), 258 | ), 259 | ); 260 | } 261 | 262 | Widget _buildFeatureRow( 263 | BuildContext context, 264 | IconData icon, 265 | String title, 266 | String subtitle, 267 | ) { 268 | final isAmoled = 269 | Provider.of(context, listen: false).appThemeMode == 270 | AppThemeMode.amoled; 271 | return Row( 272 | children: [ 273 | Container( 274 | padding: const EdgeInsets.all(12), 275 | decoration: BoxDecoration( 276 | color: Colors.white.withOpacity(0.05), 277 | borderRadius: BorderRadius.circular(12), 278 | border: isAmoled ? Border.all(color: Colors.white24) : null, 279 | ), 280 | child: Icon( 281 | icon, 282 | color: isAmoled ? Colors.white : AppTheme.primary, 283 | size: 24, 284 | ), 285 | ), 286 | const SizedBox(width: 16), 287 | Expanded( 288 | child: Column( 289 | crossAxisAlignment: CrossAxisAlignment.start, 290 | children: [ 291 | Text( 292 | title, 293 | style: const TextStyle( 294 | color: Colors.white, 295 | fontSize: 16, 296 | fontWeight: FontWeight.bold, 297 | ), 298 | ), 299 | const SizedBox(height: 4), 300 | Text( 301 | subtitle, 302 | style: TextStyle( 303 | color: Colors.white.withOpacity(0.5), 304 | fontSize: 14, 305 | ), 306 | ), 307 | ], 308 | ), 309 | ), 310 | ], 311 | ); 312 | } 313 | } 314 | --------------------------------------------------------------------------------